diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ad8e07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +specs +.local +bin +logs +coverage.out diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b38bdd..3234fb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,53 +1,41 @@ -name: CI - +name: Cloud checks on: push: - branches: [ main, master ] + branches: [main, master] pull_request: - branches: [ main, master ] - jobs: - lint: - name: Lint & Format Check + check: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.11 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ora_test + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 2s + --health-timeout 3s + --health-retries 20 + env: + TEST_DATABASE_URL: 'host=127.0.0.1 port=5432 user=postgres password=postgres dbname=ora_test sslmode=disable' + REQUIRE_POSTGRES: '1' + CGO_ENABLED: '1' steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: '1.27' - check-latest: true + go-version-file: go.mod cache: true - - - name: Download dependencies - run: go mod download - - - name: Build project - run: go build ./... - + - run: go mod download + - run: go run ./cmd/checkformat - name: Install golangci-lint run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest - - name: Run golangci-lint run: | golangci-lint migrate --skip-validation || true golangci-lint run - - test: - name: Test & Coverage - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.27' - check-latest: true - cache: true - - - name: Run unit tests with race detector - run: go test -v -race -coverprofile=coverage.out ./... + - run: go test -race -count=1 -v -coverpkg=./internal/... -coverprofile=coverage.out ./... + - run: go build ./cmd/server ./cmd/cloudctl ./cmd/simulator diff --git a/.gitignore b/.gitignore index 8995eea..fa339d3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ go.work.sum bin/ logs/ *.db +.local/ +/specs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f36b7d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,171 @@ +# specs Repository + +`specs/` is an independent Git repository. Use `git -C specs` to inspect its status and history when +changing its contents. It owns ADRs, core test cases, and domain documentation; read +`specs/AGENTS.md` before changing anything under that directory. Changes to state transitions, +ownership, persistence, external side effects, recovery, security, or compatibility must keep the +relevant approved ADRs and core-test evidence synchronized with the implementation. + +# Go + +Ora Cloud is an authoritative Go service. Preserve the boundaries documented in `README.md`, +`docs/core-contract.md`, `docs/execution-contract.md`, and `docs/authentication.md`. The Go version +in `go.mod`, repository tasks in `Taskfile.yml`, and checks in `.golangci.yml` are authoritative. + +1. **Document intent**: Exported packages, types, functions, methods, and constants must have + idiomatic doc comments. Document non-obvious unexported code when the contract or invariant is + not evident from its name. Write code comments in English and explain why, including ownership, + security, transaction, ordering, and recovery constraints; let names and structure explain what. +2. **Keep APIs idiomatic and explicit**: Use `MixedCaps`, avoid package-name stutter, accept + interfaces at the consuming boundary, and return concrete types by default. Keep interfaces + small and define them where they are consumed. Avoid boolean switches, ambiguous `nil` values, + and bags of optional fields when typed constants, separate methods, or validated request types + make the call site and valid states clear. +3. **Design for testability**: Inject databases, clocks, external clients, and other effects. Keep + domain decisions independent of Gin, GORM, filesystem, and process wiring where practical. + Prefer small deterministic functions for policy and state transitions. Do not introduce global + mutable state or hidden singleton dependencies. +4. **Handle errors deliberately**: Return errors for expected failure paths, add useful operation + context with `%w`, and inspect causes with `errors.Is` or `errors.As`. Handle each error exactly + once; never both log and return it unless crossing a process or protocol boundary requires both. + Panic/recover is limited to a package-internal control-flow boundary that converts known panic + values back to errors; unexpected panics must remain visible. Public responses use the stable + `Fault` contract and never expose SQL, stack, credential, or infrastructure detail. +5. **Own cancellation and concurrency**: Pass `context.Context` as the first parameter for work + whose lifetime can end, and propagate it to SQL, HTTP, and process calls. Every goroutine must + have an explicit owner, cancellation path, and completion strategy. Protect shared state through + ownership or synchronization, keep critical sections bounded, and verify concurrent changes + with the race detector. +6. **Keep resources scoped**: Close response bodies, rows, statements, files, processes, and + timers on every path. Acquire a resource next to the cleanup registration when possible. Use + `t.Cleanup` for test resources and preserve the existing graceful-shutdown behavior in commands. +7. **Preserve compatibility boundaries**: Architectural cleanup is preferred over carrying + accidental internal abstractions. PostgreSQL data, migration history, published HTTP/OpenAPI + behavior, durable filesystem layout, identifiers, and authentication/trust semantics are hard + compatibility boundaries. Evolve them with an approved decision, an explicit migration or + versioning plan, and regression evidence; never reinterpret existing durable state silently. + +## Packages and dependencies + +- Keep `cmd/*` limited to configuration, dependency wiring, lifecycle, and exit behavior. Put + authoritative state and policy in `internal/core`, HTTP translation and authentication at the + router boundary, database connection setup in `internal/repository`, and development-only + execution doubles in `internal/simulator`. +- New implementation packages should be private under `internal/`. Add code to `pkg/` only when it + is intentionally reusable by external modules and its API can be supported as public surface. +- Keep the public surface minimal. Constructors must return ready-to-use values or an error; do not + expose partially initialized objects. Preserve zero-value usefulness only where it is honest. +- Prefer the standard library and existing dependencies. Before adding a module, justify the + capability, maintenance, license, security, and binary-size cost. When dependencies change, keep + `go.mod` and `go.sum` tidy and include both in the same change. +- Use `filepath.Join`, `filepath.Clean`, and other platform-aware APIs for filesystem paths. Never + concatenate path separators. Treat all paths, URLs, refs, IDs, and configuration as untrusted + input at their boundary. +- Use `time.Time` and `time.Duration` rather than numeric time units. Persist instants as PostgreSQL + `timestamptz`; use the authoritative database time for leases and fencing, and convert to local + display time only at a presentation boundary. +- Prefer cohesive files and packages. Target implementation files below roughly 500 lines, + excluding tests and generated artifacts. When a file approaches 800 lines, add new behavior in a + focused sibling file unless keeping it together protects a stronger invariant. Keep tests and + package/type documentation close to the code that owns the behavior. +- Avoid thin helpers used once when inline code is clearer. Extract a helper when it names an + invariant, centralizes error-prone policy, enables focused tests, or has genuine reuse. + +## PostgreSQL and transactions + +- PostgreSQL is the sole authoritative persistence implementation. Inject the database handle; + do not add an in-memory, SQLite, MySQL, or package-global alternative to bypass real behavior. +- Schema changes are explicit, ordered SQL files under `internal/core/migrations`. Applied files are + immutable because their checksums are verified. Add a forward migration for every later change, + make it safe to retry, and test both a fresh database and an upgrade from the previous schema. +- The server validates migration state and never runs DDL or `AutoMigrate` at startup. Deployment + changes use `cloudctl migrate` with a separately authorized migration identity. +- Keep transactions short and database-only. Never hold a transaction, row lock, or advisory lock + across HTTP, Git, filesystem, process, Node, or Substrate work. Use parameterized SQL, check every + database error, and encode cross-row integrity in PostgreSQL constraints when the database can + enforce it more reliably than application code. +- Preserve tenant and owner scope in every query and relationship. Resource lookup must not turn an + authorization failure into a data leak. Soft deletion must retain the references required for + termination, cleanup, audit, and recovery. +- Persist an external-effect plan and stable idempotency key before dispatching a mutation. On an + ambiguous result, reconcile by stable external identity; never infer success, overwrite a live + binding, or discard recovery evidence. Lease epochs and resource versions must fence stale + actors on every write. + +## HTTP, contracts, and security + +- `router.Routes`, `internal/contract`, and `api/openapi.json` describe one contract. When a route, + field, status, or response changes, update all three, run `task openapi`, and add contract and + integration coverage in the same change. Generated OpenAPI output must have no hand edits. +- Decode requests strictly: retain body limits, reject malformed JSON, extra JSON values, unknown + fields, invalid types, and server-owned identity or scope fields. Validate at the boundary and + pass explicit trusted values inward. +- Keep service and end-user credentials independent. Authorization derives only from verified + claims and current PostgreSQL state, never caller-selected headers or body fields. Preserve + issuer, audience, key-purpose, role, caller, tenant, owner, workspace, sandbox, generation, and + epoch bindings where applicable. +- Credentials remain infrastructure references. Never accept, persist, log, return, or commit raw + deployment private keys, access tokens, passwords, or repository credentials. Generated test keys + may exist only in isolated process memory or temporary paths and must never reach logs or durable + fixtures. Logs use structured fields and request/resource IDs while excluding secrets and + unnecessary personal data. +- Public endpoints return stable, bounded error shapes. Internal details belong in structured logs; + clients receive actionable codes and safe parameters. Health checks report dependency readiness + without disclosing configuration. + +## Tests + +`task check` runs format verification, lint, and the complete PostgreSQL-backed test suite. It can be +slow, so run the smallest relevant task while iterating and run the full gate before considering a +repository-wide or behavior-changing change complete. Use `task --list` for the authoritative task +list. + +- Format changed Go files: `task format` +- Verify formatting without edits: `task format:check` +- Lint: `task lint` +- Unit tests without PostgreSQL: `task test:unit` +- PostgreSQL integration tests: `task test:integration` +- Complete test suite with mandatory PostgreSQL: `task test` +- Complete format, lint, and test gate: `task check` +- Race-enabled full suite: `task test:race` +- Build server and operational commands: `task build` + +`task check`, `task test`, `task test:integration`, and `task test:race` require a real PostgreSQL +database through `TEST_DATABASE_URL`; they must fail rather than silently skip when +`REQUIRE_POSTGRES=1`. Follow `README.md` for the supported local PostgreSQL setup. Race tests on +Windows require a working C compiler. + +- Add or update tests with every behavior change. Prefer table-driven tests for genuine input + matrices, compare complete values when practical, and make failure messages identify the case and + violated invariant. +- Tests must be deterministic and hermetic within their declared boundary. Use isolated PostgreSQL + schemas and temporary directories, register cleanup immediately, and avoid arbitrary sleeps, + wall-clock assumptions, test-order dependencies, and mutable process-global configuration. +- Use `t.Parallel` only after proving the test and its helpers do not share a schema, environment, + port, filesystem path, logger, or other mutable state. Concurrent behavior must synchronize the + start and assert the complete set of permitted outcomes, not merely accept the most common one. +- Integration tests exercise real HTTP, PostgreSQL constraints, disk, and Git where the contract + crosses those boundaries. A mock or simulator can cover fault injection but cannot replace the + real-boundary acceptance test. +- Changes to transactions, leases, fencing, idempotency, recovery, shared caches, or goroutines must + pass `task test:race`. Changes to command wiring or startup/shutdown must also pass `task build`. +- When a behavior maps to a core test case under `specs/test-cases`, keep its stable path/anchor and + evidence status accurate. A lower-level test counts as evidence only when its failure directly + shows that the stated obligation is broken. + +## Change workflow + +1. Read the nearest `AGENTS.md`, relevant docs, existing tests, and approved ADRs before designing + the change. Identify compatibility boundaries and failure/recovery paths before editing code. +2. Implement the smallest coherent change. Keep schema, code, OpenAPI, docs, tests, and core-test + evidence in the same change when they describe one behavior. +3. Run `task format`, then the narrowest relevant lint/test loop until it is green. Never weaken a + lint, skip, timeout, assertion, or security check merely to make the gate pass; fix the cause or + document an approved exception next to the narrowest possible suppression. +4. Run `task check` for behavior or repository-wide changes, plus `task test:race` for concurrency + or persistence lifecycle changes and `task build` for command changes. Completion requires every + applicable gate to pass with no unexplained skips, race reports, leaked resources, or generated + diff. +5. Review `git diff --check`, the complete diff, and both repository statuses (`git status --short` + and `git -C specs status --short`). Confirm that logs and fixtures contain no secrets and that no + unrelated user changes were overwritten. diff --git a/README.md b/README.md index b1a2436..3d5ef1d 100644 --- a/README.md +++ b/README.md @@ -1,195 +1,102 @@ -# Cloud Go Backend Service - -基于 Go 语言构建的标准企业级微服务/后端工程骨架,遵循社区规范 [golang-standards/project-layout](https://github.com/golang-standards/project-layout) 与 Clean Architecture 分层设计模式。 - ---- - -## 🛠 技术栈与核心特性 - -- **Web 框架**: [Gin](https://github.com/gin-gonic/gin)(高性能 HTTP 路由与中间件处理) -- **持久化 ORM**: [GORM](https://gorm.io/)(集成连接池管理、自动表结构迁移、支持 SQLite 与 MySQL 双驱动) -- **日志框架**: [Uber Zap](https://github.com/uber-go/zap) + [Lumberjack](https://github.com/natefinch/lumberjack)(结构化输出、日志切割与归档、终端色彩输出与文件 JSON 输出双引擎) -- **配置管理**: [Viper](https://github.com/spf13/viper)(YAML 配置文件与环境变量自动映射) -- **高可用与生命周期**: HTTP 优雅停机(Graceful Shutdown,监听系统退出信号平滑关闭连接与释放资源) -- **工程设计**: 统一 RESTful API JSON 响应封装、Zap 请求与 Panic Recovery 全局中间件、CORS 跨域支持 - ---- - -## 📁 目录规范说明 - -```text -. -├── cmd/ -│ └── server/ -│ └── main.go # 服务主入口:装配各层依赖、初始化基础设施、启动服务并监听停机信号 -├── configs/ -│ └── config.yaml # 默认配置文件(服务器端口、数据库 DSN、日志级别及轮转策略) -├── internal/ # 应用核心私有代码 (内部包,外部项目无法直接 import) -│ ├── api/ -│ │ ├── handler/ # 控制器层 (Handler):解析与校验 HTTP 入参,组装返回响应 -│ │ ├── middleware/ # Gin 中间件:Zap 日志追踪、Panic 恢复、CORS -│ │ └── router/ # 路由注册:装配全局中间件与 API 路由分组 -│ ├── config/ # 配置结构体映射与加载逻辑 -│ ├── model/ # 业务实体 (Entity / DTO / GORM 映射模型) -│ ├── repository/ # 数据持久层 (DAO / Repository):负责数据库 CRUD 与连接池维护 -│ └── service/ # 业务逻辑层 (Service):核心业务规则编排 -├── pkg/ # 公共可复用包 (可供外部仓库或其它微服务共享) -│ ├── logger/ # 基于 Zap + Lumberjack 封装的通用日志工具 -│ └── response/ # 统一 RESTful 响应格式封装 -├── scripts/ # 构建与运维脚本 -│ ├── Dockerfile # 多阶段轻量级 Dockerfile -│ └── Makefile # 常用开发脚本 (build/run/test/clean) -├── go.mod # Go 依赖包管理文件 -├── go.sum # 依赖校验哈希 -└── README.md # 项目说明文档 -``` +# Ora Cloud ---- +阶段一实现:Go/Gin cloud 核心、PostgreSQL 权威持久化、内部认证和有限控制契约,以及使用真实 HTTP、PG、磁盘和 Git 的模拟执行组件。此仓库尚未完成 Rust Controller/Workspace Node 拆分、Desktop 重构或 Kubernetes 部署。 -## 🚀 快速开始 +需要 Go 1.27.1、Git、PostgreSQL 17 和可选的 Task。数据库通过 GORM 初始化并注入,事务层执行参数化 PostgreSQL SQL;没有全局 DB、SQLite/MySQL 示例用户 CRUD,也没有生产启动 AutoMigrate。 -### 1. 安装依赖 +## 本地验证 -确保本地已安装 Go (建议 1.20+),在项目根目录下执行: +Windows 可在项目 `.local/` 隔离安装并启动 PostgreSQL 17.11,不创建系统服务: -```bash -go mod tidy +```powershell +./scripts/postgres.ps1 start +$env:TEST_DATABASE_URL='host=127.0.0.1 port=55432 user=postgres dbname=ora_test sslmode=disable' +task check ``` -### 2. 启动服务 - -```bash -# 方式一:直接运行 -go run cmd/server/main.go +脚本仅监听 `127.0.0.1:55432`,使用本地测试 trust 认证。二进制来自 [EDB PostgreSQL Windows 分发](https://www.enterprisedb.com/download-postgresql-binaries),固定版本和 SHA256。停止用 `./scripts/postgres.ps1 stop`,数据保留。 -# 方式二:指定自定义配置文件 -go run cmd/server/main.go -config configs/config.yaml +也可使用 Docker: -# 方式三:使用 Makefile -make run +```sh +docker compose up -d --wait +export TEST_DATABASE_URL='host=127.0.0.1 port=55432 user=ora password=ora-local dbname=ora sslmode=disable' +task check +task test:race ``` -服务默认在 `http://localhost:8080` 启动,并自动创建本地 SQLite 数据库文件 `cloud.db`。 - ---- +测试为每个用例建立独立 PG schema 并自动清理,测试账号需要 CREATE SCHEMA 权限。`task check/test/test:integration/test:race` 会设置 `REQUIRE_POSTGRES=1`;缺少真实 PG 配置会失败,不能静默跳过。直接 `go test ./...` 未配置 PG 时会显式跳过 integration,用 `task test:unit` 可单独运行非 PG 测试。 -## 📡 API 接口说明 +Windows race 需要可用 C 编译器: -| 请求方法 | 接口路径 | 描述 | -| :--- | :--- | :--- | -| `GET` | `/api/v1/health` | 服务健康检查探针 | -| `POST` | `/api/v1/users` | 创建用户 (JSON Body) | -| `GET` | `/api/v1/users` | 分页获取用户列表 (`?page=1&page_size=10`) | -| `GET` | `/api/v1/users/:id` | 根据用户 ID 查询用户详情 | - -### 请求示例 - -#### 1. 健康检查 -```bash -curl -X GET http://localhost:8080/api/v1/health -``` -响应: -```json -{ - "code": 0, - "message": "success", - "data": { - "service": "cloud-backend", - "status": "UP", - "timestamp": "2026-09-08T17:28:00+08:00" - } -} +```powershell +$env:CC='D:\tmp\ora-cloud-test-tools\llvm-mingw-20260908-ucrt-x86_64\bin\x86_64-w64-mingw32-gcc.exe' +$env:PATH=(Split-Path $env:CC)+';'+$env:PATH +task test:race ``` -#### 2. 创建用户 -```bash -curl -X POST http://localhost:8080/api/v1/users \ - -H "Content-Type: application/json" \ - -d '{"username": "developer", "nickname": "Coder", "email": "dev@example.com"}' -``` +该路径是本次验证使用的隔离工具目录,其他机器设置自己的 MinGW/LLVM-MinGW `CC` 即可。Linux CI 使用系统 C 编译器。 ---- - -## ⚙️ 配置说明 (`configs/config.yaml`) - -```yaml -server: - port: 8080 - mode: "debug" # debug / release / test - read_timeout: 10 # 读超时 (秒) - write_timeout: 10 # 写超时 (秒) - -logger: - level: "info" # 日志级别: debug / info / warn / error - filename: "logs/app.log" # 日志持久化路径 - max_size: 100 # 单个日志文件最大尺寸 (MB) - max_backups: 10 # 最多保留旧日志文件数 - max_age: 30 # 保留天数 - compress: true # 是否 gzip 压缩旧日志 - enable_console: true # 是否同时输出至终端控制台 - -database: - driver: "sqlite" # 支持 sqlite 或 mysql - dsn: "cloud.db" # 数据库连接串 - max_idle_conns: 10 # 最大空闲连接数 - max_open_conns: 100 # 最大打开连接数 - conn_max_lifetime: 3600 # 连接可复用的最大时间 (秒) - auto_migrate: true # 启动时是否自动迁移建表 -``` +## 运行 -> **提示**:若切换至 MySQL,仅需将 `driver` 改为 `mysql`,并将 `dsn` 修改为类似 `"user:password@tcp(127.0.0.1:3306)/cloud?charset=utf8mb4&parseTime=True&loc=Local"`。 +配置文件默认 `configs/config.yaml`,所有已有配置项可由 `CLOUD_` 环境变量覆盖,如 `CLOUD_DATABASE_DSN`。生产数据库使用 TLS、独立 DML 账号和部署迁移账号;不要使用示例本地 trust 配置。 ---- - -## 🔍 代码规范与质量工程 (Format & Lint) - -本项目引入了 Go 社区最严格、最高标准的工程化质量保证体系(对标 Rust 的 `cargo fmt` 与 `cargo clippy`): - -| 维度 | Rust 工具生态 | Go 对应方案(本项目采用) | 说明 | -| :--- | :--- | :--- | :--- | -| **代码格式化** | `rustfmt` | **`gofumpt`** + **`goimports`** | 比默认 `gofmt` 更严格的语法与空行规范,自动排序与分组 package import | -| **静态分析与代码检查** | `clippy` (`clippy.toml`) | **`golangci-lint`** (`.golangci.yml`) | 业界统治级多引擎 Linter(集成 govet、errcheck、staticcheck、revive、gocritic、gosec 等 10+ 款检查器) | -| **任务命令编排** | `cargo` / `Taskfile` | **`go-task`** (`Taskfile.yml`) + `Makefile` | 跨平台 Task 命令,无缝适配 Windows / macOS / Linux | -| **编辑器统一规范** | `.editorconfig` | **`.editorconfig`** | 强制 Go 统一使用 Hard Tabs,缩进宽度为 4,文件末尾换行 | -| **CI 持续集成** | GitHub Actions | **`.github/workflows/ci.yml`** | 提交代码或 PR 时自动执行全量格式校验、静态检查与竞态测试 | - -### 常用质量命令 (通过 Task 或 Make) - -```bash -# 1. 自动格式化代码 (对标 cargo fmt) -task fmt -# 或 -make fmt +```powershell +# 默认示例指向上面的本地 ora_test;真实部署请指定 -config。 +go run ./cmd/cloudctl -command migrate +go run ./cmd/cloudctl -command bootstrap -name '研发组织' -source 'huawei-corp' -subject 'stable-account-id' -display-name '首位管理员' +go run ./cmd/cloudctl -command credential-ref -tenant '' -owner '' -secret-ref 'infra-secret://git/team/account' +``` -# 2. 静态代码分析与异味检查 (对标 cargo clippy) -task lint -# 或 -make lint +`bootstrap` 原子创建租户与首位管理员,是部署操作;重复执行会新建租户。`credential-ref` 只保存基础设施引用,不接收 Git 密钥值;引用受 tenant+owner 外键约束。普通成员须先经有效 gateway 身份访问 `/api/v1/me` 建立 user,再由管理员通过成员 API 显式添加。没有自助组织注册或外部组自动授权。 -# 3. 自动修复可修复的 lint 警告 -task lint:fix +生产启动前在配置中设置内部验证公钥,见 [认证配置与凭据](docs/authentication.md)。空 trust 配置会启动失败: -# 4. 全量质量门禁 (格式校验 + 静态分析 + 单元测试,推荐作为 pre-commit 检查) -task check -# 或 -make check +```sh +go run ./cmd/server -config /path/to/config.yaml ``` ---- +server 只检查已执行迁移及 checksum,不执行 DDL;数据库、迁移、trust 或监听失败会非零退出。`GET /healthz` 检查 PG 可达性。 -## 🧪 测试与构建 +可直接运行完整创建演示(独立生成短期模拟签名密钥,仅限进程内测试): -```bash -# 执行单元测试 -task test -# 或 -make test - -# 编译为生产二进制包 -task build -# 或 -make build +```sh +go run ./cmd/cloudctl -command migrate +go run ./cmd/simulator +``` -# 构建 Docker 容器镜像 -make docker-build -``` \ No newline at end of file +演示启动独立 loopback HTTP cloud/Substrate,创建测试租户、bare repo、main linked worktree、模拟 sandbox 和 Node,再输出 Ready Workspace。磁盘在 `.local/demo/`,PG 记录保留;再次运行创建新的演示租户。模拟器没有生产基础设施凭据,不部署 Kubernetes,不启动真实 Agent/Deno。 + +## 契约与边界 + +- [OpenAPI 3.0](api/openapi.json):所有 19 个公开接口、15 个内部接口和 health。`task openapi` 重新生成,测试校验文档合法性、生成结果和实际 HTTP 响应结构。 +- [核心不变量与状态机](docs/core-contract.md):身份、归属、幂等、准入、租约、恢复和清理。 +- [Substrate/Node 与阶段二边界](docs/execution-contract.md):共享卷布局、维护 Job、容器挂载、Git 语义与迁移责任。 +- [需求—实现—验证清单](docs/acceptance.md):本次实际证据与未完成的阶段二验证。 + +## 模块架构与分层文档 + +每个子系统、服务命令与工具均遵循与 Ora 桌面端同等严谨的架构设计,并配备独立的模块级规范文档: + +- **命令与运维入口 (`cmd/`)**:[入口总览 (`cmd/`)](cmd/README.md) + - [服务守护进程 (`cmd/server`)](cmd/server/README.md):生产环境 HTTP Daemon 核心。 + - [运维管理工具 (`cmd/cloudctl`)](cmd/cloudctl/README.md):迁移执行、初始租户引导与凭据引用配置。 + - [本地执行模拟器 (`cmd/simulator`)](cmd/simulator/README.md):内存与磁盘执行双工演示。 + - [OpenAPI 同步工具 (`cmd/openapi`)](cmd/openapi/README.md):从 Go 契约自动编译导出 `api/openapi.json`。 + - [代码格式严检门禁 (`cmd/checkformat`)](cmd/checkformat/README.md):CI 格式静态门禁。 +- **内部核心子系统 (`internal/`)**:[子系统总览 (`internal/`)](internal/README.md) + - [领域状态机引擎 (`internal/core`)](internal/core/README.md):聚合根、事务与全局锁、乐观版本控制、租约与幂等。 + - [PostgreSQL 迁移目录 (`internal/core/migrations`)](internal/core/migrations/README.md):0001~0004 线性 SQL 迁移与校验和防篡改校验。 + - [HTTP 路由网关 (`internal/api/router`)](internal/api/router/README.md):Gin 路由分流、双重 JWT 校验、白名单与 Fault 映射。 + - [API 契约定义 (`internal/contract`)](internal/contract/README.md):OpenAPI 3.0 数据模型与测试。 + - [数据库连接池管理 (`internal/repository`)](internal/repository/README.md):GORM 连接池、快速探活与安全约束。 + - [配置解析与加载 (`internal/config`)](internal/config/README.md):Viper 强类型配置与环境变量映射。 + - [结构化日志 (`internal/logger`)](internal/logger/README.md):Zap + Lumberjack 轮转与平台适配。 + - [执行替身 (`internal/simulator`)](internal/simulator/README.md):Substrate、Node 与 Controller 开发期替身。 +- **公共库与集成测试**: + - [公共导出边界 (`pkg/`)](pkg/README.md):公共库导出策略与约束。 + - [集成测试套件 (`integration/`)](integration/README.md):基于独立真实 PG Schema 的全链路集成测试。 + +阶段一采用数据库事务级全局 advisory lock 串行核心事务,并限制每 Project 一个未完成 operation。HTTP/Git/Substrate 调用从不持有数据库事务。此选择适用于首版单集群单活,牺牲写吞吐以降低并发不变量复杂度;后续可按租户/Project 细分锁,但必须保持现有并发测试。 + +容器只打包 server/cloudctl,运行身份为非 root。构建用 `docker build -f scripts/Dockerfile -t ora-cloud:phase-one .`,挂载自有配置和公钥;迁移使用同镜像 `--entrypoint /app/cloudctl` 独立执行。仓库 CI 使用 PG service、格式/静态检查和 race 集成测试。Docker 镜像和真实部署不属于本地已验证结果。 diff --git a/Taskfile.yml b/Taskfile.yml index e0b679b..68cd5a0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,72 +1,50 @@ -# https://taskfile.dev -# Taskfile is the modern Go-native task runner (cross-platform, replaces Makefile on Windows/macOS/Linux) version: '3' - -vars: - MODULE_NAME: github.com/wanglongan587/cloud - BINARY_NAME: bin/server - MAIN_FILE: cmd/server/main.go - tasks: default: - desc: 列出所有可用命令 - cmds: - - task --list - + cmds: [task --list] format: - desc: 执行代码格式化 (使用严格规范的 gofumpt 与自动组织 import 的 goimports) aliases: [fmt] cmds: - - go run mvdan.cc/gofumpt -w -extra . - - go run golang.org/x/tools/cmd/goimports -w -local {{.MODULE_NAME}} . - silent: false - + - go tool gofumpt -w -extra . + - go tool goimports -w -local github.com/wanglongan587/cloud . format:check: - desc: 检查代码格式是否规范 (不修改文件,用于 CI 校验) - cmds: - - go run mvdan.cc/gofumpt -d -extra . - + cmds: [go run ./cmd/checkformat] lint: - desc: 执行静态代码分析 (使用业界标杆 golangci-lint) - cmds: - - go run github.com/golangci/golangci-lint/cmd/golangci-lint run -c .golangci.yml - + cmds: [go tool golangci-lint run -c .golangci.yml] lint:fix: - desc: 自动修复代码静态分析中可自动修复的问题 - cmds: - - go run github.com/golangci/golangci-lint/cmd/golangci-lint run -c .golangci.yml --fix - + cmds: [go tool golangci-lint run -c .golangci.yml --fix] test: - desc: 运行所有单元测试并生成覆盖率报告 - cmds: - - go test -v -coverprofile=coverage.out ./... - + desc: All tests; real PostgreSQL is mandatory + env: + REQUIRE_POSTGRES: '1' + cmds: [go test -count=1 -v -coverpkg=./internal/... -coverprofile=coverage.out ./...] + test:integration: + desc: Real HTTP + PostgreSQL + disk + Git acceptance tests + env: + REQUIRE_POSTGRES: '1' + cmds: [go test -count=1 -v ./integration] + test:unit: + cmds: [go test ./internal/... ./cmd/...] test:race: - desc: 运行数据竞态检测测试 (需要 CGO 支持) - cmds: - - go test -v -race -coverprofile=coverage.out ./... - + desc: Full suite with race detector; Windows requires a C compiler + env: + REQUIRE_POSTGRES: '1' + CGO_ENABLED: '1' + cmds: [go test -race -count=1 -v ./...] check: - desc: 全量综合质量门禁 (format:check + lint + test) cmds: - task: format:check - task: lint - task: test - build: - desc: 编译生成生产二进制可执行文件 cmds: - - go build -ldflags="-s -w" -o {{.BINARY_NAME}} {{.MAIN_FILE}} - + - go build -o bin/ ./cmd/server ./cmd/cloudctl ./cmd/simulator run: - desc: 本地直接运行服务 - cmds: - - go run {{.MAIN_FILE}} - - clean: - desc: 清理构建产物、本地数据库与日志 - cmds: - - cmd: cmd /c "if exist bin rmdir /s /q bin & if exist logs rmdir /s /q logs & if exist coverage.out del coverage.out & if exist *.db del *.db" - platforms: [windows] - - cmd: rm -rf bin/ logs/ *.db coverage.out - platforms: [darwin, linux] + cmds: [go run ./cmd/server] + migrate: + cmds: [go run ./cmd/cloudctl -command migrate] + simulate: + desc: Demonstrate real HTTP/PG/Git lifecycle with simulated execution components + cmds: [go run ./cmd/simulator] + openapi: + cmds: [go run ./cmd/openapi] diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..19a59de --- /dev/null +++ b/api/openapi.json @@ -0,0 +1,6111 @@ +{ + "components": { + "schemas": { + "Access": { + "additionalProperties": false, + "properties": { + "allowedAction": { + "enum": [ + "read", + "execute" + ], + "type": "string" + }, + "executable": { + "type": "boolean" + }, + "runtimeGeneration": { + "format": "int64", + "type": "integer" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "userId": { + "format": "uuid", + "type": "string" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "userId", + "tenantId", + "workspaceId", + "allowedAction", + "executable", + "runtimeGeneration" + ], + "type": "object" + }, + "AdminOperation": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "state": { + "type": "string" + }, + "step": { + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "projectId", + "workspaceId", + "kind", + "state", + "step", + "version", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "AdminResource": { + "additionalProperties": false, + "properties": { + "desiredState": { + "enum": [ + "running", + "stopped", + "deleted" + ], + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "enum": [ + "main", + "isolated" + ], + "type": "string" + }, + "observedState": { + "enum": [ + "provisioning", + "starting", + "ready", + "stopping", + "stopped", + "unavailable", + "deleting", + "deleted" + ], + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "runtimeGeneration": { + "format": "int64", + "type": "integer" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "projectId", + "ownerUserId", + "kind", + "desiredState", + "observedState", + "runtimeGeneration", + "version" + ], + "type": "object" + }, + "ControllerProject": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "credentialRefId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "defaultBranch": { + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "lifecycle": { + "type": "string" + }, + "name": { + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "repositoryUrl": { + "type": "string" + }, + "secretRef": { + "nullable": true, + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "ownerUserId", + "name", + "repositoryUrl", + "defaultBranch", + "credentialRefId", + "lifecycle", + "version", + "createdAt", + "deletedAt", + "secretRef" + ], + "type": "object" + }, + "ControllerWorkspace": { + "additionalProperties": false, + "properties": { + "admissionEpoch": { + "format": "int64", + "type": "integer" + }, + "admissionOpen": { + "type": "boolean" + }, + "baseCommitId": { + "nullable": true, + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + "branchName": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "desiredState": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "type": "string" + }, + "observedState": { + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "requestedRef": { + "type": "string" + }, + "runtimeGeneration": { + "format": "int64", + "type": "integer" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "ownerUserId", + "projectId", + "kind", + "desiredState", + "observedState", + "runtimeGeneration", + "version", + "admissionOpen", + "admissionEpoch", + "createdAt", + "deletedAt", + "relativePath", + "branchName", + "requestedRef", + "baseCommitId" + ], + "type": "object" + }, + "Effect": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "externalId": { + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "type": "string" + }, + "operationId": { + "format": "uuid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "reconciledEpoch": { + "format": "int64", + "type": "integer" + }, + "request": { + "$ref": "#/components/schemas/EffectRequest" + }, + "result": { + "$ref": "#/components/schemas/EffectResult" + }, + "state": { + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "id", + "operationId", + "projectId", + "workspaceId", + "kind", + "state", + "externalId", + "request", + "result", + "reconciledEpoch", + "createdAt", + "version" + ], + "type": "object" + }, + "EffectRequest": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "storage_ensure", + "worktree_ensure", + "sandbox_ensure", + "sandbox_terminate", + "worktree_delete", + "storage_delete" + ], + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "repositoryUrl": { + "type": "string" + }, + "requestedRef": { + "type": "string" + }, + "sandboxInstanceId": { + "format": "uuid", + "type": "string" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "kind", + "projectId" + ], + "type": "object" + }, + "EffectResult": { + "additionalProperties": false, + "properties": { + "commitId": { + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + "jobTerminated": { + "type": "boolean" + }, + "layoutVersion": { + "format": "int64", + "type": "integer" + }, + "nodeId": { + "format": "uuid", + "type": "string" + }, + "removed": { + "type": "boolean" + }, + "sandboxInstanceId": { + "format": "uuid", + "type": "string" + }, + "terminated": { + "type": "boolean" + } + }, + "required": null, + "type": "object" + }, + "EmptyClaim": { + "additionalProperties": false, + "properties": { + "operation": { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "Error": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "params": { + "additionalProperties": true, + "type": "object" + }, + "requestId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "code", + "params", + "requestId" + ], + "type": "object" + }, + "IdleRefusal": { + "additionalProperties": false, + "properties": { + "accepted": { + "type": "boolean" + }, + "errorCode": { + "enum": [ + "resource_in_use" + ], + "type": "string" + } + }, + "required": [ + "accepted", + "errorCode" + ], + "type": "object" + }, + "Lease": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "type": "integer" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "holderId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "holderId", + "epoch", + "expiresAt" + ], + "type": "object" + }, + "Member": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "userId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tenantId", + "userId", + "role", + "status", + "version", + "createdAt" + ], + "type": "object" + }, + "MemberListItem": { + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "userId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "userId", + "role", + "status", + "version", + "displayName" + ], + "type": "object" + }, + "Node": { + "additionalProperties": false, + "properties": { + "connectionState": { + "type": "string" + }, + "endedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "idleAdmissionEpoch": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "initialized": { + "type": "boolean" + }, + "lastSeenAt": { + "format": "date-time", + "type": "string" + }, + "protocolVersion": { + "format": "int64", + "type": "integer" + }, + "sandboxInstanceId": { + "format": "uuid", + "type": "string" + }, + "serviceSubject": { + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id", + "sandboxInstanceId", + "serviceSubject", + "connectionState", + "protocolVersion", + "initialized", + "lastSeenAt", + "endedAt", + "idleAdmissionEpoch", + "version", + "workspaceId" + ], + "type": "object" + }, + "Operation": { + "additionalProperties": false, + "properties": { + "actorUserId": { + "format": "uuid", + "type": "string" + }, + "controllerEpoch": { + "format": "int64", + "nullable": true, + "type": "integer" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "errorCode": { + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "request": { + "$ref": "#/components/schemas/OperationRequest" + }, + "requestHash": { + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/OperationResult" + }, + "retryAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "state": { + "enum": [ + "queued", + "running", + "retry_wait", + "blocked", + "succeeded", + "failed" + ], + "type": "string" + }, + "step": { + "enum": [ + "storage", + "worktree", + "sandbox", + "node", + "ready", + "quiesce", + "terminate", + "cleanup", + "storage_delete", + "done" + ], + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "actorUserId", + "projectId", + "workspaceId", + "kind", + "state", + "step", + "request", + "result", + "errorCode", + "idempotencyKey", + "requestHash", + "controllerEpoch", + "retryAt", + "version", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "OperationRequest": { + "additionalProperties": false, + "properties": { + "previous": { + "additionalProperties": { + "$ref": "#/components/schemas/Workspace" + }, + "type": "object" + } + }, + "required": null, + "type": "object" + }, + "OperationResult": { + "additionalProperties": false, + "properties": { + "resourceId": { + "format": "uuid", + "type": "string" + } + }, + "required": null, + "type": "object" + }, + "Project": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "credentialRefId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "defaultBranch": { + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "lifecycle": { + "type": "string" + }, + "name": { + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "repositoryUrl": { + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "ownerUserId", + "name", + "repositoryUrl", + "defaultBranch", + "credentialRefId", + "lifecycle", + "version", + "createdAt", + "deletedAt" + ], + "type": "object" + }, + "Sandbox": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "generation": { + "format": "int64", + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "observedState": { + "type": "string" + }, + "substrateSandboxId": { + "nullable": true, + "type": "string" + }, + "terminatedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id", + "workspaceId", + "generation", + "substrateSandboxId", + "observedState", + "createdAt", + "terminatedAt", + "version" + ], + "type": "object" + }, + "Snapshot": { + "additionalProperties": false, + "properties": { + "effects": { + "items": { + "$ref": "#/components/schemas/Effect" + }, + "type": "array" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/Node" + }, + "type": "array" + }, + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "project": { + "$ref": "#/components/schemas/ControllerProject" + }, + "sandboxes": { + "items": { + "$ref": "#/components/schemas/Sandbox" + }, + "type": "array" + }, + "storage": { + "$ref": "#/components/schemas/Storage" + }, + "workspaces": { + "items": { + "$ref": "#/components/schemas/ControllerWorkspace" + }, + "type": "array" + } + }, + "required": [ + "operation", + "project", + "storage", + "workspaces", + "sandboxes", + "nodes", + "effects" + ], + "type": "object" + }, + "Storage": { + "additionalProperties": false, + "properties": { + "layoutVersion": { + "format": "int64", + "type": "integer" + }, + "observedState": { + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "storageProfile": { + "type": "string" + }, + "substrateStorageId": { + "nullable": true, + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "projectId", + "substrateStorageId", + "storageProfile", + "layoutVersion", + "observedState", + "version" + ], + "type": "object" + }, + "Tenant": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "status", + "role" + ], + "type": "object" + }, + "Ticket": { + "additionalProperties": false, + "properties": { + "actorUserId": { + "format": "uuid", + "type": "string" + }, + "admissionEpoch": { + "format": "int64", + "type": "integer" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "type": "string" + }, + "nodeInstanceId": { + "format": "uuid", + "type": "string" + }, + "state": { + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "workspaceId", + "nodeInstanceId", + "actorUserId", + "admissionEpoch", + "kind", + "state", + "createdAt", + "finishedAt", + "version" + ], + "type": "object" + }, + "User": { + "additionalProperties": false, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "displayName": { + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "displayName", + "status", + "version", + "createdAt", + "deletedAt" + ], + "type": "object" + }, + "Workspace": { + "additionalProperties": false, + "properties": { + "admissionEpoch": { + "format": "int64", + "type": "integer" + }, + "admissionOpen": { + "type": "boolean" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "desiredState": { + "enum": [ + "running", + "stopped", + "deleted" + ], + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "enum": [ + "main", + "isolated" + ], + "type": "string" + }, + "observedState": { + "enum": [ + "provisioning", + "starting", + "ready", + "stopping", + "stopped", + "unavailable", + "deleting", + "deleted" + ], + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "runtimeGeneration": { + "format": "int64", + "type": "integer" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "ownerUserId", + "projectId", + "kind", + "desiredState", + "observedState", + "runtimeGeneration", + "version", + "admissionOpen", + "admissionEpoch", + "createdAt", + "deletedAt" + ], + "type": "object" + }, + "WorkspaceListItem": { + "additionalProperties": false, + "properties": { + "admissionEpoch": { + "format": "int64", + "type": "integer" + }, + "admissionOpen": { + "type": "boolean" + }, + "baseCommitId": { + "nullable": true, + "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$", + "type": "string" + }, + "branchName": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "desiredState": { + "enum": [ + "running", + "stopped", + "deleted" + ], + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "kind": { + "enum": [ + "main", + "isolated" + ], + "type": "string" + }, + "observedState": { + "enum": [ + "provisioning", + "starting", + "ready", + "stopping", + "stopped", + "unavailable", + "deleting", + "deleted" + ], + "type": "string" + }, + "ownerUserId": { + "format": "uuid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "type": "string" + }, + "runtimeGeneration": { + "format": "int64", + "type": "integer" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "title": { + "nullable": true, + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "tenantId", + "ownerUserId", + "projectId", + "kind", + "desiredState", + "observedState", + "runtimeGeneration", + "version", + "admissionOpen", + "admissionEpoch", + "createdAt", + "deletedAt", + "branchName", + "baseCommitId", + "title" + ], + "type": "object" + } + }, + "securitySchemes": { + "serviceCredential": { + "bearerFormat": "EdDSA JWT", + "description": "Pinned issuer/kid/kind=service/role, aud=ora-cloud, exp and iat required, \u003c=5 minute lifetime. Public API requires gateway; internal control requires controller; nodes require scoped node role.", + "scheme": "bearer", + "type": "http" + }, + "userCredential": { + "description": "Separately signed EdDSA JWT: kind=user, source+sub, caller must equal authenticated service sub, aud=ora-cloud. User and membership status checked in PostgreSQL.", + "in": "header", + "name": "X-Ora-User-Token", + "type": "apiKey" + } + } + }, + "info": { + "description": "Authoritative PostgreSQL core. Simulation is separate; no production Controller/Node/Kubernetes implementation is implied.", + "title": "Ora Cloud phase one", + "version": "1.0.0" + }, + "openapi": "3.0.3", + "paths": { + "/api/v1/me": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_me", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/me" + } + }, + "/api/v1/me/tenants": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_me_tenants", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor, ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Tenant" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/me/tenants" + } + }, + "/api/v1/tenants/{tid}/members": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Administrator only. Updating an existing membership requires matching version; new membership uses version=0. Last effective administrator cannot be disabled/demoted, including concurrent changes. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_members", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor, ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MemberListItem" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/members" + } + }, + "/api/v1/tenants/{tid}/members/{uid}": { + "put": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Administrator only. Updating an existing membership requires matching version; new membership uses version=0. Last effective administrator cannot be disabled/demoted, including concurrent changes. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "put_api_v1_tenants_tid_members_uid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "role": { + "enum": [ + "admin", + "member" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "disabled" + ], + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "role", + "status" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "PUT /api/v1/tenants/:tid/members/:uid" + } + }, + "/api/v1/tenants/{tid}/operations/{oid}": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Operation lookup follows project owner; administrative-stop actor receives only the restricted projection. Retry only accepts blocked/retry_wait, exact operation version, and an idempotency key. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_operations_oid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Operation" + }, + { + "$ref": "#/components/schemas/AdminOperation" + } + ] + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/operations/:oid" + } + }, + "/api/v1/tenants/{tid}/operations/{oid}/retry": { + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Operation lookup follows project owner; administrative-stop actor receives only the restricted projection. Retry only accepts blocked/retry_wait, exact operation version, and an idempotency key. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_operations_oid_retry", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "oneOf": [ + { + "$ref": "#/components/schemas/Operation" + }, + { + "$ref": "#/components/schemas/AdminOperation" + } + ] + } + }, + "required": [ + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/operations/:oid/retry" + } + }, + "/api/v1/tenants/{tid}/projects": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_projects", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor, ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Project" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/projects" + }, + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Creates Project/storage/main Workspace/operation atomically. repositoryUrl allows HTTPS or SSH with no password/query/fragment. defaultBranch defaults to HEAD; credentialRefId must belong to tenant and owner. Storage/worktree/sandbox initialization is asynchronous. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_projects", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "credentialRefId": { + "format": "uuid", + "type": "string" + }, + "defaultBranch": { + "type": "string" + }, + "name": { + "type": "string" + }, + "repositoryUrl": { + "type": "string" + } + }, + "required": [ + "name", + "repositoryUrl" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Project" + }, + "workspace": { + "$ref": "#/components/schemas/Workspace" + } + }, + "required": [ + "resource", + "operation", + "workspace" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/projects" + } + }, + "/api/v1/tenants/{tid}/projects/{pid}": { + "delete": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Requires matching resource version and no active project operation. Atomically closes new execution admission. Active tickets return 409 resource_in_use without changing admission. Unknown Node activity requires later proof and remains pending/blocked. main Workspace cannot be independently deleted. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "delete_api_v1_tenants_tid_projects_pid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "DELETE /api/v1/tenants/:tid/projects/:pid" + }, + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_projects_pid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/projects/:pid" + }, + "patch": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Only project name may change; version must match. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "patch_api_v1_tenants_tid_projects_pid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "PATCH /api/v1/tenants/:tid/projects/:pid" + } + }, + "/api/v1/tenants/{tid}/projects/{pid}/workspaces": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_projects_pid_workspaces", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor, ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WorkspaceListItem" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/projects/:pid/workspaces" + }, + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Creates one isolated Workspace and Task display identity. title/baseRef required; branch and relative path are server-generated. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_projects_pid_workspaces", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "baseRef": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "baseRef" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Workspace" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/projects/:pid/workspaces" + } + }, + "/api/v1/tenants/{tid}/resource-status": { + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Administrator response explicitly excludes repository URL, worktree details, credentials, execution output and operation request/result/error details. Administrative stop still requires idle evidence. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_resource-status", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor, ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AdminResource" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/resource-status" + } + }, + "/api/v1/tenants/{tid}/workspaces/{wid}": { + "delete": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Requires matching resource version and no active project operation. Atomically closes new execution admission. Active tickets return 409 resource_in_use without changing admission. Unknown Node activity requires later proof and remains pending/blocked. main Workspace cannot be independently deleted. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "delete_api_v1_tenants_tid_workspaces_wid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "wid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Workspace" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "DELETE /api/v1/tenants/:tid/workspaces/:wid" + }, + "get": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "get_api_v1_tenants_tid_workspaces_wid", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "wid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/tenants/:tid/workspaces/:wid" + } + }, + "/api/v1/tenants/{tid}/workspaces/{wid}/administrative-stop": { + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Administrator response explicitly excludes repository URL, worktree details, credentials, execution output and operation request/result/error details. Administrative stop still requires idle evidence. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_workspaces_wid_administrative-stop", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "wid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/AdminOperation" + }, + "resource": { + "$ref": "#/components/schemas/AdminResource" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/workspaces/:wid/administrative-stop" + } + }, + "/api/v1/tenants/{tid}/workspaces/{wid}/start": { + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_workspaces_wid_start", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "wid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Workspace" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/workspaces/:wid/start" + } + }, + "/api/v1/tenants/{tid}/workspaces/{wid}/stop": { + "post": { + "description": "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. Requires matching resource version and no active project operation. Atomically closes new execution admission. Active tickets return 409 resource_in_use without changing admission. Unknown Node activity requires later proof and remains pending/blocked. main Workspace cannot be independently deleted. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_api_v1_tenants_tid_workspaces_wid_stop", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "wid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "operation": { + "$ref": "#/components/schemas/Operation" + }, + "resource": { + "$ref": "#/components/schemas/Workspace" + } + }, + "required": [ + "resource", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "POST /api/v1/tenants/:tid/workspaces/:wid/stop" + } + }, + "/healthz": { + "get": { + "operationId": "health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "ok" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + } + }, + "description": "Database reachable" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Database unavailable" + } + }, + "summary": "PostgreSQL readiness" + } + }, + "/internal/v1/access": { + "post": { + "description": "Checks final user, active membership, tenant and owner. read checks ownership; execute additionally requires current controller lease epoch, open admission, ready workspace and a fresh initialized Node. This lookup is not an execution reservation; use admissions.", + "operationId": "post_internal_v1_access", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "read", + "execute" + ], + "type": "string" + }, + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "tenantId", + "workspaceId", + "action" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Access" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "access" + } + }, + "/internal/v1/admissions": { + "post": { + "description": "Atomically reserves an active task/interaction ticket on the current Node under the same transaction lock as stop/delete. Requires current controller holder+epoch and caller-bound final-user token. Unknown/uncompleted tickets remain active; bound Node explicitly finishes them. Repeated ticket UUID with identical scope returns it while admission remains open.", + "operationId": "post_internal_v1_admissions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "read", + "execute" + ], + "type": "string" + }, + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "kind": { + "enum": [ + "task", + "interaction" + ], + "type": "string" + }, + "tenantId": { + "format": "uuid", + "type": "string" + }, + "ticketId": { + "format": "uuid", + "type": "string" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "tenantId", + "workspaceId", + "action", + "ticketId", + "kind", + "epoch" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "admit" + } + }, + "/internal/v1/controller-lease/acquire": { + "post": { + "description": "Controller subject is holderId. Global lease lasts 30 seconds using PostgreSQL clock_timestamp(); renew every 10 seconds. Expired acquisition increments epoch, active same-holder acquisition returns current lease. Release and renew require exact live holder+epoch.", + "operationId": "post_internal_v1_controller-lease_acquire", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lease" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "lease acquire" + } + }, + "/internal/v1/controller-lease/release": { + "post": { + "description": "Controller subject is holderId. Global lease lasts 30 seconds using PostgreSQL clock_timestamp(); renew every 10 seconds. Expired acquisition increments epoch, active same-holder acquisition returns current lease. Release and renew require exact live holder+epoch.", + "operationId": "post_internal_v1_controller-lease_release", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lease" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "lease release" + } + }, + "/internal/v1/controller-lease/renew": { + "post": { + "description": "Controller subject is holderId. Global lease lasts 30 seconds using PostgreSQL clock_timestamp(); renew every 10 seconds. Expired acquisition increments epoch, active same-holder acquisition returns current lease. Release and renew require exact live holder+epoch.", + "operationId": "post_internal_v1_controller-lease_renew", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lease" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "lease renew" + } + }, + "/internal/v1/nodes/idle": { + "post": { + "description": "Requires node service credential whose sub is a process UUID and whose workspaceId/sandboxId/generation match the current unterminated instance. Node identity cannot be replaced while live. Status/idle use Node version; ticket finish uses Ticket version and a completed replay is idempotent. initialized cannot regress. Idle is scoped to operationId and exact Workspace admissionEpoch; true requires no active tickets. false fails that quiesce operation with resource_in_use and restores original admission. Registration requires protocolVersion=1; Pod Running alone cannot make Ready.", + "operationId": "post_internal_v1_nodes_idle", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "admissionEpoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "idle": { + "type": "boolean" + }, + "operationId": { + "format": "uuid", + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version", + "admissionEpoch", + "idle", + "operationId" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Node" + }, + { + "$ref": "#/components/schemas/IdleRefusal" + } + ] + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "node idle" + } + }, + "/internal/v1/nodes/register": { + "post": { + "description": "Requires node service credential whose sub is a process UUID and whose workspaceId/sandboxId/generation match the current unterminated instance. Node identity cannot be replaced while live. Status/idle use Node version; ticket finish uses Ticket version and a completed replay is idempotent. initialized cannot regress. Idle is scoped to operationId and exact Workspace admissionEpoch; true requires no active tickets. false fails that quiesce operation with resource_in_use and restores original admission. Registration requires protocolVersion=1; Pod Running alone cannot make Ready.", + "operationId": "post_internal_v1_nodes_register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "protocolVersion": { + "enum": [ + 1 + ], + "type": "integer" + } + }, + "required": [ + "protocolVersion" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Node" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "node register" + } + }, + "/internal/v1/nodes/status": { + "post": { + "description": "Requires node service credential whose sub is a process UUID and whose workspaceId/sandboxId/generation match the current unterminated instance. Node identity cannot be replaced while live. Status/idle use Node version; ticket finish uses Ticket version and a completed replay is idempotent. initialized cannot regress. Idle is scoped to operationId and exact Workspace admissionEpoch; true requires no active tickets. false fails that quiesce operation with resource_in_use and restores original admission. Registration requires protocolVersion=1; Pod Running alone cannot make Ready.", + "operationId": "post_internal_v1_nodes_status", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "connectionState": { + "enum": [ + "connected", + "disconnected" + ], + "type": "string" + }, + "initialized": { + "type": "boolean" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version", + "connectionState", + "initialized" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Node" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "node status" + } + }, + "/internal/v1/nodes/tickets/{ticket}/finish": { + "post": { + "description": "Requires node service credential whose sub is a process UUID and whose workspaceId/sandboxId/generation match the current unterminated instance. Node identity cannot be replaced while live. Status/idle use Node version; ticket finish uses Ticket version and a completed replay is idempotent. initialized cannot regress. Idle is scoped to operationId and exact Workspace admissionEpoch; true requires no active tickets. false fails that quiesce operation with resource_in_use and restores original admission. Registration requires protocolVersion=1; Pod Running alone cannot make Ready.", + "operationId": "post_internal_v1_nodes_tickets_ticket_finish", + "parameters": [ + { + "in": "path", + "name": "ticket", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "node finish" + } + }, + "/internal/v1/operations/claim": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay.", + "operationId": "post_internal_v1_operations_claim", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/Snapshot" + }, + { + "$ref": "#/components/schemas/EmptyClaim" + } + ] + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "claim" + } + }, + "/internal/v1/operations/{oid}/advance": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Derives the next step server-side. Requires current-epoch successful effects. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. node step atomically commits worktree readiness, Workspace Ready/admission, and operation success after fresh initialized current Node. Cleanup and storage deletion cannot complete before termination confirmation.", + "operationId": "post_internal_v1_operations_oid_advance", + "parameters": [ + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch", + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Operation" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "advance" + } + }, + "/internal/v1/operations/{oid}/defer": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Preserves operation/effect/resource references and current step; sets blocked or retry_wait with bounded retry delay. Never reports cleanup success on timeout.", + "operationId": "post_internal_v1_operations_oid_defer", + "parameters": [ + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "errorCode": { + "enum": [ + "substrate_timeout", + "termination_unconfirmed", + "git_cleanup_failed", + "node_unavailable", + "external_failure" + ], + "type": "string" + }, + "retrySeconds": { + "maximum": 3600, + "minimum": 1, + "type": "integer" + }, + "state": { + "enum": [ + "blocked", + "retry_wait" + ], + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch", + "version", + "state", + "errorCode", + "retrySeconds" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Operation" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "defer" + } + }, + "/internal/v1/operations/{oid}/effects": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Only the effect kind appropriate to the current step is allowed. Scope is restricted to operation workspaces. Plan persists BEFORE dispatch; sandbox plan atomically increments generation and allocates a unique live instance. Old instance must be confirmed terminated. Same plan returns the same effect ID.", + "operationId": "post_internal_v1_operations_oid_effects", + "parameters": [ + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "kind": { + "enum": [ + "storage_ensure", + "worktree_ensure", + "sandbox_ensure", + "sandbox_terminate", + "worktree_delete", + "storage_delete" + ], + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "workspaceId": { + "type": "string" + } + }, + "required": [ + "epoch", + "version", + "kind" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "effect": { + "$ref": "#/components/schemas/Effect" + }, + "operation": { + "$ref": "#/components/schemas/Operation" + } + }, + "required": [ + "effect", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "plan" + } + }, + "/internal/v1/operations/{oid}/effects/{eid}/result": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Reports/reconciles one scoped external effect. External ID cannot change; succeeded evidence is immutable. absent is allowed only for a planned effect. Worktree success requires real commitId and jobTerminated; cleanup requires removed and jobTerminated; termination requires terminated; storage requires layoutVersion=1; sandbox requires its preallocated instance ID. This endpoint trusts the authenticated controller's Substrate observation, not client-supplied status.", + "operationId": "post_internal_v1_operations_oid_effects_eid_result", + "parameters": [ + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "eid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "externalId": { + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/EffectResult" + }, + "state": { + "enum": [ + "running", + "succeeded", + "failed", + "absent" + ], + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch", + "version", + "state", + "result" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "effect": { + "$ref": "#/components/schemas/Effect" + }, + "operation": { + "$ref": "#/components/schemas/Operation" + } + }, + "required": [ + "effect", + "operation" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "effect result" + } + }, + "/internal/v1/operations/{oid}/snapshot": { + "post": { + "description": "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. Operation lookup follows project owner; administrative-stop actor receives only the restricted projection. Retry only accepts blocked/retry_wait, exact operation version, and an idempotency key. Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination.", + "operationId": "post_internal_v1_operations_oid_snapshot", + "parameters": [ + { + "in": "path", + "name": "oid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "epoch": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "epoch", + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Snapshot" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + } + }, + "security": [ + { + "serviceCredential": [] + } + ], + "summary": "snapshot" + } + } + }, + "servers": [ + { + "url": "http://localhost:8080" + } + ] +} diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..cc830b7 --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,22 @@ +# cmd: Command Entrypoints + +`cmd` hosts the command-line and daemon entrypoints for Ora Cloud. Packages in this directory are +strictly limited to runtime configuration loading, dependency injection, process lifecycle wiring, +operating-system signal handling, and process exit codes. + +## Module map + +- [server](server/README.md) is the primary authoritative HTTP API service daemon. +- [cloudctl](cloudctl/README.md) is the restricted deployment and operations CLI for migrations, tenant bootstrap, and credential reference management. +- [simulator](simulator/README.md) provides an all-in-one local demo environment backed by in-process Substrate and Git execution doubles. +- [openapi](openapi/README.md) compiles and synchronizes the canonical OpenAPI 3.0 specification (`api/openapi.json`) from Go contract definitions. +- [checkformat](checkformat/README.md) enforces repository Go formatting standards as a strict failing CI gate. + +## Boundaries and invariants + +- **No domain logic**: `cmd/*` packages contain zero domain policy, state transition algorithms, or transactional logic. All domain behavior belongs to `internal/core`. +- **No direct database queries**: Commands acquire database pools exclusively via `internal/repository` and hand them directly to `internal/core.NewStore`. No raw SQL, GORM models, or queries exist in `cmd/*`. +- **Resource cleanup on exit**: Process termination must flush logging buffers (`logger.Sync`), close database connection pools (`store.Pool.Close()`), and cleanly cancel background contexts. +- **Fail-fast on startup**: Commands immediately abort with non-zero exit codes if configuration loading, schema checksum verification, database ping, or cryptographic trust verification fails. + +See the [top-level README](../README.md), [internal packages](../internal/README.md), and [AGENTS.md](../AGENTS.md). diff --git a/cmd/checkformat/README.md b/cmd/checkformat/README.md new file mode 100644 index 0000000..f2d5390 --- /dev/null +++ b/cmd/checkformat/README.md @@ -0,0 +1,15 @@ +# cmd/checkformat: Code Formatting Gate + +`cmd/checkformat` is a formatting validation tool used in local checks and CI pipelines to enforce strict Go code layout. + +## Responsibilities + +- **Non-mutating format inspection**: Executes `gofumpt -l -extra .` across the repository to list any files that violate formatting rules without altering them on disk. +- **Strict gate enforcement**: Exits with code 0 if all Go source files adhere to formatting standards. If any improperly formatted file is detected, it prints the violating filenames to `stderr` and exits with code 1, prompting the developer to run `task format`. + +## Boundaries and invariants + +- **Read-only**: `cmd/checkformat` never writes to or modifies any source files. Automated formatting is performed separately via `task format` (`gofumpt` and `goimports`). +- **Standardized check**: Integrates directly with `task format:check` and `task check`. + +See [cmd overview](../README.md) and [Taskfile.yml](../../Taskfile.yml). diff --git a/cmd/checkformat/main.go b/cmd/checkformat/main.go new file mode 100644 index 0000000..034ba58 --- /dev/null +++ b/cmd/checkformat/main.go @@ -0,0 +1,20 @@ +// checkformat makes formatting a failing gate instead of merely printing a diff. +package main + +import ( + "fmt" + "os" + "os/exec" +) + +func main() { + output, e := exec.Command("go", "tool", "gofumpt", "-l", "-extra", ".").CombinedOutput() + if e != nil { + fmt.Fprintln(os.Stderr, string(output), e) + os.Exit(1) + } + if len(output) != 0 { + fmt.Fprintln(os.Stderr, "Run task format:\n"+string(output)) + os.Exit(1) + } +} diff --git a/cmd/cloudctl/README.md b/cmd/cloudctl/README.md new file mode 100644 index 0000000..bbb5cbb --- /dev/null +++ b/cmd/cloudctl/README.md @@ -0,0 +1,38 @@ +# cmd/cloudctl: Operational & Deployment CLI + +`cloudctl` is the restricted operational management tool for Ora Cloud. It runs with database operator credentials outside the public API path to manage database migrations, provision initial administrative tenants, and register infrastructure secret references. + +## Commands and responsibilities + +### `migrate` +```sh +cloudctl -config -command migrate +``` +- Applies all unapplied forward migrations from `internal/core/migrations` in strict numerical order. +- Computes SHA256 checksums of migration scripts and records them in `schema_migrations`. +- Runs within transactional database-level advisory locks (`pg_advisory_xact_lock(67420911)`), making concurrent or repeated execution deterministic and safe. +- Rejects checksum mismatches on previously applied migrations with an error. + +### `bootstrap` +```sh +cloudctl -config -command bootstrap -name '' -source '' -subject '' -display-name '' +``` +- Atomically provisions an initial active tenant and its first administrator user in a single database transaction. +- Binds external IdP identity claims (`source` and `subject`) to an internal user record. +- Assigns the user the `admin` role in `tenant_memberships`. +- Returns a JSON payload containing `tenantId` and `userId`. + +### `credential-ref` +```sh +cloudctl -config -command credential-ref -tenant '' -owner '' -secret-ref 'infra-secret://git/team/account' +``` +- Registers an infrastructure credential reference for Git operations. +- Enforces foreign key constraints: the target user must be an active member of the specified tenant. +- **Security invariant**: Only stores the URI/reference string (`secret-ref`). It never accepts, logs, or stores plaintext passwords, tokens, or private keys. + +## Boundaries and invariants + +- **Deployment boundary**: `cloudctl` is not exposed via HTTP and is not invoked by the server daemon. It requires direct network access to PostgreSQL with schema migration permissions. +- **Structured output**: All command results are output as structured JSON to `stdout`, and errors to `stderr`, enabling integration with CI/CD deployment pipelines. + +See [cmd overview](../README.md), [Database migrations](../../internal/core/migrations/README.md), and [Authentication and trust](../../docs/authentication.md). diff --git a/cmd/cloudctl/main.go b/cmd/cloudctl/main.go new file mode 100644 index 0000000..c6f6af9 --- /dev/null +++ b/cmd/cloudctl/main.go @@ -0,0 +1,64 @@ +// cloudctl is the restricted deployment management path, run with database operator credentials. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/wanglongan587/cloud/internal/config" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/repository" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + configFile := flag.String("config", "", "configuration file") + command := flag.String("command", "", "migrate | bootstrap | credential-ref") + name := flag.String("name", "", "tenant name") + source := flag.String("source", "", "stable identity namespace") + subject := flag.String("subject", "", "identity subject") + display := flag.String("display-name", "", "display name") + tenant := flag.String("tenant", "", "tenant UUID") + owner := flag.String("owner", "", "owner UUID") + secret := flag.String("secret-ref", "", "infrastructure secret reference, never a secret value") + flag.Parse() + cfg, e := config.Load(*configFile) + if e != nil { + return e + } + db, e := repository.InitDB(context.Background(), cfg.Database) + if e != nil { + return e + } + s, e := core.NewStore(db) + if e != nil { + return e + } + defer s.Pool.Close() + ctx := context.Background() + var out core.Object + switch *command { + case "migrate": + e = s.Migrate(ctx) + out = core.Object{"migrated": e == nil} + case "bootstrap": + out, e = s.Bootstrap(ctx, *name, *source, *subject, *display) + case "credential-ref": + out, e = s.ConfigureCredential(ctx, *tenant, *owner, *secret) + default: + return fmt.Errorf("unknown command; choose migrate, bootstrap, credential-ref") + } + if e != nil { + return e + } + return json.NewEncoder(os.Stdout).Encode(out) +} diff --git a/cmd/openapi/README.md b/cmd/openapi/README.md new file mode 100644 index 0000000..f03e282 --- /dev/null +++ b/cmd/openapi/README.md @@ -0,0 +1,19 @@ +# cmd/openapi: OpenAPI Document Generator + +`cmd/openapi` is a code-generation and synchronization tool that outputs the canonical OpenAPI 3.0 specification from Go contract definitions. + +## Responsibilities + +- **Contract compilation**: Calls `internal/contract.Document()`, which constructs the authoritative OpenAPI 3.0 document representing all 19 public endpoints, 15 internal control endpoints, and the health check endpoint. +- **Artifact synchronization**: Serializes the document into indented JSON and writes it to `api/openapi.json`. +- **Single Source of Truth (SSOT)**: Guarantees that `api/openapi.json`, `router.Routes()`, and `internal/contract` stay strictly aligned. + +## Invariants + +- **No hand edits**: `api/openapi.json` must never be modified manually. All route, request body, query parameter, or status code changes must be made in `internal/contract` and `internal/api/router`, then regenerated using: + ```sh + task openapi + ``` +- **CI verification**: CI enforces that the committed `api/openapi.json` matches the output of `cmd/openapi` exactly, failing if there is any uncommitted schema drift. + +See [cmd overview](../README.md), [Contract package](../../internal/contract/README.md), and [HTTP router](../../internal/api/router/README.md). diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go new file mode 100644 index 0000000..9280497 --- /dev/null +++ b/cmd/openapi/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/wanglongan587/cloud/internal/contract" +) + +func main() { + b, e := json.MarshalIndent(contract.Document(), "", " ") + if e == nil { + e = os.MkdirAll("api", 0o755) + } + if e == nil { + e = os.WriteFile("api/openapi.json", append(b, '\n'), 0o600) + } + if e != nil { + fmt.Fprintln(os.Stderr, e) + os.Exit(1) + } +} diff --git a/cmd/server/README.md b/cmd/server/README.md new file mode 100644 index 0000000..3185b41 --- /dev/null +++ b/cmd/server/README.md @@ -0,0 +1,20 @@ +# cmd/server: Ora Cloud HTTP Daemon + +`cmd/server` is the main production entrypoint for the authoritative Ora Cloud service. It binds HTTP endpoints, validates database migration checksums, verifies JWT credentials, and manages the daemon lifecycle under operating system signals. + +## Responsibilities + +- **Configuration & logging**: Loads application settings via `internal/config.Load` (supporting YAML files and `CLOUD_*` environment variable overrides) and initializes the process-wide Zap logger. +- **PostgreSQL pool initialization**: Connects to the authoritative PostgreSQL database via `internal/repository.InitDB` and verifies connectivity with a mandatory ping. +- **Migration integrity gate**: Executes `store.CheckSchema(ctx)` at startup. It strictly verifies that all migrations defined in `internal/core/migrations` have been applied in order with matching SHA256 checksums, and that no unexpected migration versions exist. It never applies DDL or runs `AutoMigrate`. +- **Cryptographic trust setup**: Constructs the `core.Authenticator` using configured trusted verification public keys and the expected audience string. +- **HTTP server composition**: Initializes the Gin engine via `internal/api/router.New`, applies server timeouts (`ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`), and listens on the configured TCP port. +- **Graceful shutdown**: Intercepts `os.Interrupt` and `syscall.SIGTERM`. On receipt of a shutdown signal, it allocates a bounded 10-second shutdown context to finish in-flight requests and cleanly closes the PostgreSQL pool and logger buffers. + +## Boundaries and invariants + +- **No startup DDL**: The server never mutates the database schema at runtime. Missing or altered migrations immediately cause a fatal startup exit; schema updates must be performed using `cloudctl migrate`. +- **Stateless daemon**: The server process maintains no in-memory mutable business state across requests. Authoritative state resides entirely within PostgreSQL and is synchronized using database-level advisory locking. +- **Zero credential leaks**: The server does not handle or log raw external deployment keys, cloud provider secrets, or infrastructure passwords. + +See [cmd overview](../README.md), [HTTP router](../../internal/api/router/README.md), [Authentication](../../docs/authentication.md), and [Core contract](../../docs/core-contract.md). diff --git a/cmd/server/main.go b/cmd/server/main.go index 50aa914..1e4a7fc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "net/http" @@ -10,86 +11,69 @@ import ( "syscall" "time" + "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/wanglongan587/cloud/internal/api/router" "github.com/wanglongan587/cloud/internal/config" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/logger" "github.com/wanglongan587/cloud/internal/repository" - "github.com/wanglongan587/cloud/pkg/logger" ) func main() { - var configPath string - flag.StringVar(&configPath, "config", "", "path to configuration file") - flag.Parse() - - // 1. Load configuration - cfg, err := config.Load(configPath) - if err != nil { - fmt.Printf("Failed to load config: %v\n", err) + if e := run(); e != nil { + fmt.Fprintln(os.Stderr, e) os.Exit(1) } +} - // 2. Initialize logger - log, err := logger.Init(cfg.Logger) - if err != nil { - fmt.Printf("Failed to init logger: %v\n", err) - os.Exit(1) +func run() (runErr error) { + configPath := flag.String("config", "", "configuration file") + flag.Parse() + cfg, e := config.Load(*configPath) + if e != nil { + return e } - defer logger.Sync() - - log.Info("Configuration and logger initialized successfully") - - // 3. Initialize database - db, err := repository.InitDB(cfg.Database) - if err != nil { - log.Error("Failed to initialize database", zap.Error(err)) - return + log, e := logger.New(cfg.Logger) + if e != nil { + return e } - log.Info("Database initialized successfully", zap.String("driver", cfg.Database.Driver)) - - // 4. Initialize HTTP router - r := router.InitRouter(cfg) - - // 5. Configure HTTP server - addr := fmt.Sprintf(":%d", cfg.Server.Port) - srv := &http.Server{ - Addr: addr, - Handler: r, - ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second, + defer func() { runErr = errors.Join(runErr, logger.Sync(log)) }() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + db, e := repository.InitDB(ctx, cfg.Database) + if e != nil { + return e } - - // 6. Start server in goroutine + store, e := core.NewStore(db) + if e != nil { + return e + } + defer func() { runErr = errors.Join(runErr, store.Pool.Close()) }() + if e := store.CheckSchema(ctx); e != nil { + return e + } + auth, e := core.NewAuthenticator(cfg.Auth.Audience, cfg.Auth.Keys) + if e != nil { + return e + } + gin.SetMode(cfg.Server.Mode) + server := &http.Server{Addr: fmt.Sprintf(":%d", cfg.Server.Port), Handler: router.New(store, auth, log), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: 60 * time.Second} + failed := make(chan error, 1) go func() { - log.Info("Starting HTTP server", zap.String("addr", addr)) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error("HTTP server failed to start", zap.Error(err)) - } + log.Info("Cloud listening", zap.String("address", server.Addr)) + failed <- server.ListenAndServe() }() - - // 7. Graceful Shutdown - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - log.Info("Shutting down server gracefully...") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - log.Error("Server forced to shutdown", zap.Error(err)) - } - - // Close database connections - if sqlDB, err := db.DB(); err == nil { - if err := sqlDB.Close(); err != nil { - log.Error("Failed to close database connections", zap.Error(err)) - } else { - log.Info("Database connections closed successfully") + select { + case e = <-failed: + if errors.Is(e, http.ErrServerClosed) { + return nil } + return e + case <-ctx.Done(): + shutdown, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + return server.Shutdown(shutdown) } - - log.Info("Server exited properly") } diff --git a/cmd/simulator/README.md b/cmd/simulator/README.md new file mode 100644 index 0000000..ca1e1a6 --- /dev/null +++ b/cmd/simulator/README.md @@ -0,0 +1,25 @@ +# cmd/simulator: End-to-End Local Execution Simulator + +`cmd/simulator` provides a complete local demonstration harness for Ora Cloud's phase-one architecture. It spins up in-process execution doubles, an ephemeral Git repository fixture, and loopback HTTP services to validate the full project lifecycle without external cloud infrastructure. + +## Responsibilities + +- **Durable demo fixture**: Initializes a real local Git repository fixture under `.local/demo/fixture` with initial commits and branches. +- **In-process cloud server**: Launches an ephemeral `httptest.Server` serving the complete Gin router, backed by a real PostgreSQL database. +- **Substrate simulation**: Launches a local HTTP server exposing Substrate storage and effect simulation under `.local/demo/substrate`. +- **Ephemeral cryptography**: Generates in-memory Ed25519 keypairs for `gateway`, `controller`, `node`, and `user` roles to sign and verify short-lived JWT tokens without external IdP infrastructure. +- **Controller execution loop**: + 1. Bootstraps a demo tenant and user. + 2. Acquires a controller lease via `/internal/v1/controller-lease/acquire`. + 3. Dispatches a project creation request via the public API (`POST /api/v1/tenants/{tid}/projects`). + 4. Simulates Controller queue draining: executes the effect plan (allocating project storage, provisioning Git worktrees, scheduling sandboxes, and registering nodes). + 5. Verifies that the workspace reaches `ready` state and queries it through the public API. + 6. Releases the controller lease cleanly. + 7. Emits JSON summary to `stdout`. + +## Boundaries and invariants + +- **Testing and demonstration only**: The simulator is an engineering double. It does not interface with Kubernetes, deploy real container sandboxes, or launch live Deno or agent runtimes. +- **Real boundaries preserved**: Uses real HTTP framing, real PostgreSQL schema constraints, and real local Git CLI operations; it does not bypass the domain state machine. + +See [cmd overview](../README.md), [Simulator internals](../../internal/simulator/README.md), and [Execution contract](../../docs/execution-contract.md). diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go new file mode 100644 index 0000000..73692bb --- /dev/null +++ b/cmd/simulator/main.go @@ -0,0 +1,132 @@ +// simulator runs the phase-one cloud flow over real HTTP/PostgreSQL and a disk-backed Git simulator. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/wanglongan587/cloud/internal/api/router" + "github.com/wanglongan587/cloud/internal/config" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/repository" + "github.com/wanglongan587/cloud/internal/simulator" +) + +func main() { + if e := run(); e != nil { + fmt.Fprintln(os.Stderr, e) + os.Exit(1) + } +} + +func run() error { + file := flag.String("config", "", "configuration file for a dedicated migrated simulation database") + root := flag.String("root", ".local/demo", "durable simulator storage") + flag.Parse() + cfg, e := config.Load(*file) + if e != nil { + return e + } + db, e := repository.InitDB(context.Background(), cfg.Database) + if e != nil { + return e + } + store, e := core.NewStore(db) + if e != nil { + return e + } + defer store.Pool.Close() + ctx := context.Background() + if e := store.CheckSchema(ctx); e != nil { + return e + } + absolute, e := filepath.Abs(*root) + if e != nil { + return e + } + repo := filepath.Join(absolute, "fixture") + if e := os.MkdirAll(repo, 0o700); e != nil { + return e + } + if _, err := os.Stat(filepath.Join(repo, ".git")); os.IsNotExist(err) { + for _, args := range [][]string{{"init", "--initial-branch=main", repo}} { + if e := git(args...); e != nil { + return e + } + } + if e := os.WriteFile(filepath.Join(repo, "README.md"), []byte("Ora phase-one real Git fixture\n"), 0o600); e != nil { + return e + } + if e := git("-C", repo, "add", "."); e != nil { + return e + } + if e := git("-C", repo, "-c", "user.name=Ora Simulator", "-c", "user.email=simulator@example.invalid", "commit", "-m", "fixture"); e != nil { + return e + } + } + credentials, e := simulator.NewCredentials() + if e != nil { + return e + } + auth, e := core.NewAuthenticator("ora-cloud", credentials.Trust) + if e != nil { + return e + } + gin.SetMode(gin.ReleaseMode) + cloud := httptest.NewServer(router.New(store, auth, zap.NewNop())) + defer cloud.Close() + substrate, e := simulator.NewSubstrate(filepath.Join(absolute, "substrate"), map[string]string{"https://example.invalid/repo.git": repo}) + if e != nil { + return e + } + external := httptest.NewServer(substrate) + defer external.Close() + subject := "demo-" + uuid.NewString() + tenant, e := store.Bootstrap(ctx, "Phase one demo", "simulator", subject, "Demo user") + if e != nil { + return e + } + client := &simulator.Client{URL: cloud.URL, Credentials: credentials, HTTP: &http.Client{Timeout: 30 * time.Second}, Subject: "controller-" + uuid.NewString()} + controller := &simulator.Controller{Client: client, SubstrateURL: external.URL} + if e := controller.Acquire(ctx); e != nil { + return e + } + user := &core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: subject}, Source: "simulator"} + gateway := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "simulator-gateway"}} + created, status, e := client.Call(ctx, "POST", "/api/v1/tenants/"+tenant.S("tenantId")+"/projects", "gateway", gateway, user, "demo-create", core.Object{"name": "Demo project", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main"}) + if e != nil || status != 202 { + return fmt.Errorf("create: %d %v %v", status, e, created) + } + if e := controller.Drain(ctx); e != nil { + return e + } + workspace, status, e := client.Call(ctx, "GET", "/api/v1/tenants/"+tenant.S("tenantId")+"/workspaces/"+created.O("workspace").S("id"), "gateway", gateway, user, "", nil) + if e != nil || status != 200 { + return fmt.Errorf("workspace: %d %v", status, e) + } + if _, e = client.Control(ctx, "/internal/v1/controller-lease/release", core.Object{"epoch": controller.Epoch}); e != nil { + return e + } + return json.NewEncoder(os.Stdout).Encode(core.Object{"phase": "cloud core + simulated execution", "tenantId": tenant.S("tenantId"), "projectId": created.O("resource").S("id"), "workspace": workspace, "storageRoot": substrate.Root}) +} + +func git(args ...string) error { + b, e := exec.Command("git", args...).CombinedOutput() + if e != nil { + return fmt.Errorf("git: %w: %s", e, b) + } + return nil +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..7bd13d3 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:17.11 + environment: + POSTGRES_USER: ora + POSTGRES_PASSWORD: ora-local + POSTGRES_DB: ora + ports: ['127.0.0.1:55432:5432'] + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ora -d ora'] + interval: 2s + timeout: 3s + retries: 20 +volumes: + pgdata: diff --git a/configs/config.yaml b/configs/config.yaml index 86838cc..e8d9e67 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,8 +1,8 @@ server: port: 8080 mode: "debug" # debug | release | test - read_timeout: 10 - write_timeout: 10 + read_timeout: 10s + write_timeout: 10s logger: level: "info" # debug | info | warn | error @@ -14,12 +14,11 @@ logger: enable_console: true database: - # 支持 sqlite 或 mysql - driver: "sqlite" - # sqlite 示例: cloud.db - # mysql 示例: "root:123456@tcp(127.0.0.1:3306)/cloud?charset=utf8mb4&parseTime=True&loc=Local" - dsn: "cloud.db" + driver: "postgres" + dsn: "host=127.0.0.1 port=55432 user=postgres dbname=ora_test sslmode=disable" max_idle_conns: 10 max_open_conns: 100 - conn_max_lifetime: 3600 # 秒 - auto_migrate: true + conn_max_lifetime: 1h +auth: + audience: "ora-cloud" + keys: [] # Required at server startup; see docs/authentication.md. diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..304bc7a --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,55 @@ +# 阶段一验收记录 + +2026-09-09,工作区 `D:\project\cloud`。已完成阶段一 cloud 核心与模拟执行契约,不等同于真实 Controller/Node/Kubernetes 上线。源码未提交、未推送、未部署;desktop 只读参考,独立 `specs/` 仓库没有修改。 + +## 实际运行环境与结果 + +- Windows amd64,Go 1.27.1,Git,隔离 PostgreSQL 17.11(`.local/postgres`,`127.0.0.1:55432`,测试库 `ora_test`)。每个集成用例独立 schema,测试结束清理 schema。 +- `task check`:通过,包含严格格式检查、原有 golangci-lint 规则和全部测试;PG 被强制要求,不存在 SQLite/mock repository 替代或静默跳过。19 个顶层集成测试,加凭据/约束子用例,全部通过;最后一次 integration 用时 23.328s,跨 internal 包语句覆盖 83.4%。覆盖率仅是辅助信息,不代替下列不变量证据。 +- `task test:race`:通过,`CGO_ENABLED=1`,使用隔离 LLVM-MinGW 20260908 编译器;最后一次 integration 用时 33.860s,无 race 报告。 +- `task build`:server/cloudctl/simulator 全部构建成功。 +- `go run ./cmd/cloudctl -command migrate`:迁移 0001–0004 成功且可重入;集成测试既在新 schema 运行迁移两次,也从含数据的 0003 schema 升级并验证回填。server 检查全部 migration checksum,并拒绝来自更新二进制的未知 migration;启动时不执行迁移。 +- `go run ./cmd/simulator`:真实 HTTP/PG/Git 演示完成,输出 `observedState=ready`、`admissionOpen=true`、generation=1;磁盘保留 bare repo 和 linked main worktree。此命令没有启动真正的 Rust Node/Agent。 +- 空 trust 配置运行 server:实际非零退出(exit 1),没有退回匿名访问。PG/监听失败也由启动入口返回错误。 +- `git diff --check`:通过。仅存在 Windows 行尾提示,不存在 patch 空白错误。 + +本机详细输出位于忽略目录 `.local/check-final.txt`、`.local/race-final.txt`、`.local/demo-result.json`,不会作为源码提交。测试入口和运行命令见 [README](../README.md)。CI 配置与这些门禁一致,但尚未推送运行远端 CI;本机没有 Docker,未构建/运行容器镜像。 + +## 需求—代码—直接验证 + +| 需求 | 实现 | 直接证据与结论 | +|---|---|---| +| PG、UUID/timestamptz、显式 migration、依赖注入 | `internal/repository/db.go`、`internal/core/store.go`、`internal/core/migrations/0001_core.sql`–`0004_effect_intent_and_ticket_scope.sql` | 每个 integration setup 使用真实 PG 全新 schema,迁移连续执行两次;`TestMigrateUpgradesPreviousSchemaAndData` 验证从含数据的上一 schema 升级;无 AutoMigrate/全局 DB/旧用户 CRUD | +| 非对称内部签名、服务/最终用户分别验证 | `internal/core/auth.go`、`internal/api/router/router.go` | `TestCredentialVerificationAndDisabledAccounts`:伪造、none、过期、未来、超长、缺 exp、错 aud/issuer/caller、错角色均拒绝;`TestDatabaseEffectAndTicketScopes`:gateway key 不能伪造 controller role | +| 并发首次登录唯一,source 不依赖协议/name/email | `store.go:identity`、user_identities 联合唯一 | `TestIdentityConcurrencyMembershipAndIsolation` 20 并发请求只产生一个新 user,无孤儿;凭据测试验证相同 subject 不同 source 不合并 | +| 无成员不准入,停用保留归属 | `membership`、`access` | `TestIdentityConcurrencyMembershipAndIsolation`、`TestRemainingPublicContractsAndMembershipRevocation`:创建/读取/执行被拒绝,PG 资源 owner 仍保留 | +| tenant+owner 隔离,admin 不能读取内容 | `public.go` SQL过滤、`adminResource/adminOperation` | `TestIdentityConcurrencyMembershipAndIsolation`:跨用户 Project/Workspace/operation 404,跨租户拒绝,列表为空;admin status/stop/op没有 request/result/repo/secret | +| 最后有效管理员并发保护 | membership事务和 PG 延迟触发器 | `TestConcurrentIdempotencyAndLastAdminProtection`:并发自降级恰一成功一409;`TestPostgresAggregateConstraints`:直接 SQL停用最后成员/用户被拒绝 | +| Project/main 原子聚合、复合 FK、隔离 Task 身份 | migrations 0001–0004、createProject/insertWorkspace | `TestPostgresAggregateConstraints`:缺main、移main、删main、跨归属Workspace、main关联Task、跨租户operation与跨owner ticket均拒绝;HTTP创建验证storage/main/operation事务与Task | +| 运行实例唯一、绑定不覆盖 | sandbox/node unique/FK、planEffect/nodeCommand | `TestPostgresAggregateConstraints`:第二live sandbox被拒绝;`TestOperationPreconditionsAndScheduledRetry`:不能替换live Node;生命周期测试确认重启generation++、Workspace ID和数据不变 | +| credential_refs受控配置/归属 | `cloudctl -command credential-ref`、ConfigureCredential、复合 FK | `TestPostgresAggregateConstraints`:为成员配置引用后,另一owner使用返回404;OpenAPI/公开JSON无secretRef | +| 19公开+15内部接口的可执行契约 | `router.Routes`、`internal/contract/openapi.go`、`api/openapi.json` | `TestPublishedOpenAPIIsValidAndCurrent`验证合法性/同步;集成客户端对所有cloud响应运行OpenAPI验证,补测members/tenants/workspace list/rename/access等正向路径 | +| POST/DELETE 幂等,重试在版本校验前 | idempotency_records、Public事务 | `TestConcurrentIdempotencyAndLastAdminProtection`:16并发只创建一组资源/operation;`TestHTTPProjectLifecycleAndDurableRecovery`同键不同内容409;`TestTerminationUnknownRetryAndVersionedReplay`旧version原样重放原operation | +| 分页、稳定排序、仅改名和严格输入 | `page`、PATCH分支、router字段白名单/类型检查 | `TestListPaginationAndErrorShape`、`TestRemainingPublicContractsAndMembershipRevocation`:UUID游标、范围限制、错误结构、rename version;禁止repo修改、客户端宿主路径、URL密码 | +| 真实创建流程,Ready需要Node初始化 | 控制有限状态机、模拟磁盘Substrate/真实Git | `TestHTTPProjectLifecycleAndDurableRecovery`:真实commit、bare repo、main linked worktree、isolated/Task;提前advance/伪造success/错effect步骤被`TestOperationPreconditionsAndScheduledRetry`拒绝 | +| 创建与Project删除并发 | Project操作唯一+事务锁+lifecycle检查 | `TestProjectCreationDeletionSerializationAndStrictInputs`:恰一方接受,另一冲突,没有逃逸Workspace | +| 停止与执行并发,保护待交互 | execution_tickets、关闭准入、scoped Node idle | `TestStopAdmissionRaceAndIdleEvidence`:真实interaction阻止停止,admit/stop竞争恰一方成功;缺idle不能推进;Node拒绝恢复原准入;没有常量0绕过 | +| idle证据绑定正确操作和实例 | node_idle operationId+workspace集合+epoch/version | `TestWrongWorkspaceNodeCannotRefuseAnotherStop`:同Project旁支Node不能取消另一Workspace的stop;旧Node在生命周期测试中拒绝 | +| Project删除等待全部终止/维护清理 | quiesce→terminate→cleanup→storage_delete | `TestProjectDeleteClosesEveryWorkspaceAndWaitsForCleanup`:任一Workspace活动阻止整项删除,关闭后两者都不能准入;Git清理失败不调度storage_delete,所有引用保留;恢复后整项删除 | +| Lease数据库时间、epoch接管/fencing | lease/claim/operation/准入检查 | `TestControllerTakeoverReconcilesAndFences`:过期接管epoch++,旧推进/续租/释放/admit均409,存量sandbox只一份 | +| 外部成功但响应丢失,重建对象恢复 | external_effects、Substrate磁盘journal、Controller GET-before-PUT | `TestRestartRecreatesCloudSubstrateAndController`:关闭原HTTP和PG pool,重建Store/HTTP/Substrate/Controller,原ID恢复;Git和sandbox不重复创建;幂等响应仍在PG | +| 未确认终止/清理失败不误报成功 | defer/retry与受控推进、保留绑定 | `TestTerminationUnknownRetryAndVersionedReplay`:blocked仍保留live实例,start失败,显式retry后恢复;生命周期/Project删除测试:cleanup失败保留数据和operation | +| 定时重试、版本与迟到结果拒绝 | operation retry_at/version/epoch与效果绑定 | `TestOperationPreconditionsAndScheduledRetry`:未到期不领取,到期协调原effect;旧version/完成后迟到结果拒绝 | +| 跨HTTP权威写入不能错配关联 | PG复合FK、有限内部接口 | `TestDatabaseEffectAndTicketScopes`:直接SQL ticket指向其他Workspace Node、effect指向其他Project operation都失败;模拟Controller没有DB handle | + +## 明确限制和后续验证 + +阶段一核心验收使用真实 PG/HTTP/磁盘/Git;模拟沙盒和 Node 没有真实进程隔离能力。以下没有通过本阶段验收,不能作为已完成能力宣传: + +1. Rust Controller/Node、Deno/Agent/PTY执行、真实Node本地idle与启动执行锁,以及所有Session/Workflow活动接入。现在提供真实云端票据边界及模拟回归,不提供完整后续业务领域。 +2. K8s/Substrate部署、真实异步维护Job终止、跨宿主RWX锁/原子操作、卷/容器部分挂载的Git metadata路径可移植性、storage fence在网络分区下阻止旧写入。 +3. 华为登录实际SDK/协议、生产内部签发端与密钥分发/TLS、真实私有Git凭据注入。cloud内部验证与引用约束已实现,外部基础设施适配仍需落地。 +4. Session历史、Workflow、Effect、插件归属与desktop历史导入。迁移约束已记录在 [execution-contract.md](execution-contract.md),未改写/导入原桌面数据。 +5. 性能压测与多租户Controller分片。当前全局短事务锁和每Project一个未完成operation是首版有意采用的限制。 + +Docker/远端CI未运行是本机环境验证边界;SQL迁移、HTTP/PG并发、恢复、race、本地模拟命令和Go构建没有遗留失败。 diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..3069592 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,54 @@ +# 内部认证 + +Gateway 独占外部登录集成。Cloud 不接入密码、SAML、OAuth 客户端或华为 SDK,也不根据姓名/email 合并账号。Gateway 规范化出 `{source, subject, displayName?}`;`source` 是长期稳定的账号命名空间,`(source,subject)` 联合唯一。 + +HTTP 使用两种独立签名凭据:`Authorization: Bearer ` 证明调用服务,`X-Ora-User-Token: ` 证明最终用户。公开 API 要求 gateway 服务;访问检查/执行准入要求 controller 服务及用户凭据;后台控制 API 只要求 controller 服务;Node 接口只要求带资源范围的 node 服务凭据。普通 header 不能替代任何一类签名。 + +验证器仅允许 `EdDSA` / Ed25519,校验 `kid`、配置中的 issuer、key purpose(user/service)、固定服务角色、`aud`、签名、必须存在的 `iat/exp`、未来签发时间及不超过 5 分钟的生命期。`nbf` 存在时也由 JWT 验证器校验。用户凭据 `caller` 必须精确匹配 service `sub`。每个用户请求在 PG 检查用户状态与有效成员;停用记录保留,不自动改写资源拥有者。 + +配置示例(路径是 cloud 可读的 Ed25519 PKIX PUBLIC KEY PEM): + +```yaml +auth: + audience: ora-cloud + keys: + - id: gateway-service-2026 + issuer: ora-internal-issuer + kind: service + role: gateway + public_key_file: /run/ora-keys/gateway-service.pem + - id: user-identity-2026 + issuer: ora-internal-issuer + kind: user + public_key_file: /run/ora-keys/user-identity.pem + - id: controller-service-2026 + issuer: ora-internal-issuer + kind: service + role: controller + public_key_file: /run/ora-keys/controller-service.pem + - id: node-service-2026 + issuer: ora-internal-issuer + kind: service + role: node + public_key_file: /run/ora-keys/node-service.pem +``` + +允许同时配置新旧 key ID 以轮换公钥,重启 server 载入配置。Cloud 从不持有这些私钥。签发权由 Gateway/受控基础设施适配器承担;这里没有另建认证中心。连接层仍应使用 TLS 或受控服务网络,JWT 不负责保密。 + +service claims 示例(JWT header 同时带 `alg=EdDSA,kid=gateway-service-2026`): + +```json +{"iss":"ora-internal-issuer","aud":["ora-cloud"],"sub":"gateway-instance-a","kind":"service","role":"gateway","iat":1788931200,"exp":1788931260} +``` + +user claims 同样独立签名,时间值在实际调用时生成: + +```json +{"iss":"ora-internal-issuer","aud":["ora-cloud"],"sub":"stable-account-id","kind":"user","source":"huawei-corp","displayName":"显示名","caller":"gateway-instance-a","iat":1788931200,"exp":1788931260} +``` + +Controller 代表用户准入时,Gateway/内部受控转发路径必须签发 `caller=controller-instance-a` 的用户凭据,不能直接转发绑定 gateway 的 token。后台阶段推进使用 controller 自己的服务凭据和 operation 中已保存的 `actorUserId`;无需原用户 token 长期有效。租约 holder 从已验证的 service `sub` 取得,不能通过 body 指定另一 holder。 + +Node 服务凭据的 `sub` 是每次进程启动新建的 Node UUID,并增加 `workspaceId`、`sandboxId`(cloud sandbox instance UUID)和 `generation`。基础设施签发端必须从获准的 sandbox plan 建立这个绑定,不能接受调用者自行选择资源。Cloud 检查当前 Workspace generation、已登记 Substrate ID、未确认终止 sandbox 和 Node 唯一性;旧 Node/旧 generation 拒绝回写。Node 进程更换前必须终止或 fence 旧实例,不能凭新的 Node UUID覆盖存活节点。 + +首次登录、伪造/过期/错 aud、错服务角色、caller 不匹配、命名空间分离、用户停用均有真实 HTTP+PG 回归测试。模拟器的临时私钥仅供本地演示和测试,不作为部署密钥。 diff --git a/docs/core-contract.md b/docs/core-contract.md new file mode 100644 index 0000000..49a20a3 --- /dev/null +++ b/docs/core-contract.md @@ -0,0 +1,56 @@ +# Cloud 核心契约 + +Cloud 是唯一业务权威存储。Gateway 转发查询/生命周期到 cloud,执行交互到 Controller;Controller 通过 `/internal/v1` 读取受限聚合快照、领取和推进操作,不持有 PG 连接。所有核心 HTTP 命令在一个短 PG 事务内完成;外部 HTTP、Git 和 Node 调用从不跨事务。 + +## 归属和管理 + +`tenant_memberships(tenant_id,user_id)` 为 Project owner 的 FK 目标。Workspace 通过 `(project_id,tenant_id,owner_user_id)` 复合 FK 继承完整归属。Project/Workspace 归属和 Workspace kind 不可更新;operation/effect/ticket/node 也有跨表作用域约束。软删保留所有运行及清理引用。 + +每个未软删 Project 通过延迟约束触发器检查恰有一个未软删 main Workspace,允许在同一事务原子创建或整体删除;不能单独删 main。partial unique index 防止两个 main。isolated Workspace 有唯一 Task 展示身份。云端 main 同样有 `workspace_worktrees` 行及 linked worktree;这与只读参考的 desktop 当前 schema 不同,未改动 desktop/specs 的现有语义。 + +角色只分 admin/member。查询在 SQL 中过滤 tenant+owner;admin 不享有跨用户业务读权限。管理员成员列表只含身份显示信息和角色状态;资源状态只含资源 UUID、owner、kind、运行状态/generation/version。administrative-stop 的响应以及 operation GET/retry 使用专门投影,不含 repositoryUrl、secretRef、worktree、request/result/error 明细。最后一个有效管理员不能被删除/停用/降级;用户停用或租户启用也受 PG 延迟约束保护。没有公共用户删除或停用 CRUD。 + +## 幂等与并发 + +POST/DELETE 需要 `Idempotency-Key`,范围是 tenant+user,保留原始 HTTP 状态与响应。hash 由 method、path、规范化 JSON map 组成;相同 key 不同内容为 409。同 key 同请求首先重放,再检查当前资源版本,因此响应丢失后的旧 version 重试不会创建第二份资源。停用成员仍先被拒绝。 + +PATCH 与生命周期动作携带整数 `version`;现存 membership PUT/operation retry/Node status/idle/ticket finish 同样使用 version。缺失必需版本为 428,不匹配为 409;已经 finished 的同版本请求重放不产生第二次写入。列表按 UUID 升序,`limit` 1–100,`after` 是排他 UUID cursor;身份过滤在分页前执行。 + +首版所有核心事务共用 PG transaction advisory lock,每个 Project 最多一个 queued/running/retry_wait/blocked operation。创建 isolated 与删除 Project 竞争同一锁和 lifecycle 检查;先建立的 intent 获得操作权,另一方冲突。全局锁是一项明确吞吐限制,不是跨 HTTP 长事务。未来细化锁时必须保持直接约束与并发测试。 + +## 持久操作和外部副作用 + +每个外部动作前先持久化 `external_effects` plan,ID 是外部幂等键。kind、operation、Project、Workspace、状态、外部 ID 和 reconciled epoch 均为显式字段。JSONB request/result 只承载 OpenAPI 中的有限参数/证据,不藏核心状态。 + +| Operation | 受控推进步骤 | +|---|---| +| create_project | storage → worktree → sandbox → node → done | +| create_workspace | worktree → sandbox → node → done | +| start | sandbox → node → done | +| stop / administrative_stop | quiesce → terminate → done | +| delete_workspace | quiesce → terminate → cleanup → done | +| delete_project | quiesce → terminate → cleanup → storage_delete → done | + +接口不接受“设 state=succeeded”这类任意写入。storage/worktree/sandbox 等阶段必须有同 epoch 成功 effect;Node 阶段必须有当前实例已初始化、connected、30 秒内 heartbeat 的 Node,才原子提交 worktree ready、Workspace Ready/开放准入和 operation success。Pod Running 或单个 Substrate 创建结果不能代替 Node 协议确认。 + +worktree 成功证据包含解析后的真实 40/64 位 commit 和维护 Job 终止确认;cleanup 包含 removed+jobTerminated;sandbox terminate 包含真实终止确认;存储删除必须等所有 sandbox 和已计划维护工作完成。Cloud 信任受认证 Controller 对 Substrate 的观察,但仍检查类型、绑定和阶段。实际证明基础设施终止是 Substrate/Node 阶段二实现的责任,不能拿 PG fencing 替代。 + +外部 ID 一经登记不可改变,已成功 effect 的结果不可改写。失败/超时保留 plan、外部引用和当前 step;`defer` 设置 retry_wait/blocked 及有限错误码,`retry` 重新入队,不凭超时推断外部未执行。模拟器在磁盘日志成功但 HTTP 响应丢失后按原 ID 查询恢复。 + +## 租约与接管 + +全局 `controller_leases(name=global)` 使用 PG `clock_timestamp()`,有效期 30 秒,约定每 10 秒续租;模拟器每个短 Step 续租。未过期 holder 不能被夺取;过期 acquire 增加 epoch,renew/release 需要精确 holder+epoch。Controller 调度前、领取、阶段结果、推进、重试安排、sandbox 分配和 execute 准入均验证有效租约;用户 read access 只做权限查询。 + +claim 会领取 queued、到期 retry_wait 或任意 running operation;同一 holder/epoch 重启后重新领取 running operation 时递增 operation version,从而 fence 仍持有旧内存快照的 worker。claim 返回当前 operation、Project/storage、全部相关 Workspace/sandbox/Node/effect。旧 epoch effect 必须先按稳定 ID 查询 Substrate,再登记本 epoch 的观察;否则 plan/advance 返回 reconcile_required。未知进行中维护 Job 先等终止或恢复同一任务,不能盲目创建另一个 Job。epoch 与 Workspace runtime_generation 是独立的。 + +`UNIQUE(workspace_id,generation)` 及唯一未终止 sandbox 保护替换。分配新 generation 前必须确认旧实例 terminated;登记新 Node 也不能覆盖活实例。数据库拒绝旧 epoch/旧实例迟到回写,但不会终止已经运行的文件写入。因此真实接管必须先查询/fence 外部进程;无法确认时保持 blocked,不能重放未知结果 prompt 或全局标记 Session 失败。 + +## 执行准入与 idle + +`POST /internal/v1/access` 校验最终用户、成员、归属和 action;execute 还校验租约和可执行状态,但不产生 reservation。执行必须另用 `POST /internal/v1/admissions` 原子创建 `execution_tickets`,绑定用户、Workspace、当前 Node、admission epoch,kind=task/interaction。Node 消费受控 Controller 传递的票据;重复 ticket UUID 不应重复执行,完整 Session/Workflow 执行去重留阶段二。 + +这是最小真实业务活动契约,不是常量计数:任何未结束票据都阻止 stop/delete;状态不明继续视为活跃。只有该票据绑定 Node 的 `/nodes/tickets/{id}/finish` 能携带当前 ticket version 结束,已结束请求的重放幂等。正式 Node 必须对所有 Agent、PTY、后台 Job、待处理交互使用此准入边界,不能旁路发起工作。 + +停止先在同一锁下检查票据并关闭 `admission_open`、递增 admission_epoch。活跃票据让公开请求返回 resource_in_use 并回滚所有变更。关闭后 Controller 请求每个目标 Node 原子检查自身活动并报告 `idle`,证据绑定 operationId+Workspace+Node+admissionEpoch+Node version;同 Project 的其他 Node 无权影响本次 stop。idle=true 时 cloud 再确认零活动票据;没有 Node/旧 heartbeat/未知状态均不能推进。idle=false 在 quiesce 阶段失败该 operation 并恢复所有原准入,不取消工作。 + +Node 本地原子 idle 与实际开始执行之间的进程锁由阶段二 Node 实现;阶段一用真实 PG/HTTP 票据并发测试验证云端竞争,且测试了 Node 拒绝与错误 Workspace 的 idle 证据,未声称运行真实 Agent。 diff --git a/docs/execution-contract.md b/docs/execution-contract.md new file mode 100644 index 0000000..bc60931 --- /dev/null +++ b/docs/execution-contract.md @@ -0,0 +1,51 @@ +# Substrate、Node 和阶段二契约 + +阶段一交付 cloud 核心及模拟组件。`internal/simulator` 的 Controller 没有数据库 handle,通过真实 HTTP 领取/推进;Substrate 在磁盘保存 effect journal,Git 用真实命令;Node 通过 scoped 签名凭据模拟注册、初始化、idle 和结束票据。没有真实 Pod、Agent、Deno 插件、PTY、跨宿主卷或 Rust 进程。 + +## 存储与挂载 + +每个 Project 一个共享卷,layoutVersion=1: + +```text +repository.git/ +workspaces/{workspace UUID}/checkout/ +workspaces/{workspace UUID}/runtime/ +``` + +repository.git 是 bare repository;main 与 isolated 都是 linked worktree。branch 固定由服务生成 `ora/{workspace UUID}`,cloud 保存 requestedRef 与最终 commitId,客户端不能指定宿主路径、Node 地址、sandbox ID。runtime 持久保存 Node 所需工作数据,stop 保留;只有 Workspace delete 清理自己的 checkout/runtime,Project delete 在全部 sandbox/维护 Job 结束后删除卷。 + +普通 Node 容器只挂自身 checkout/runtime 及共享 Git metadata,容器路径约定 `/workspace/checkout`、`/workspace/runtime`、`/project/repository.git`。生产 Substrate 必须修正 Git worktree metadata 内的路径,使 linked worktree 在该固定容器路径可用;本地模拟器使用完整本地绝对路径,未证明部分挂载在真实容器内可用。禁止挂载其他 Workspace 的未提交目录。共享 refs/objects/Git metadata 不提供同 Project 内 Git 内容保密;跨用户跨 Project 必须通过独立卷、服务 scope 与基础设施挂载权限隔离。 + +维护 Job 使用 Node 镜像的有限维护入口,可见完整 Project 卷;Controller 按 Project 串行调度 clone/init、解析 ref、worktree add/remove 和维护。普通部分挂载 Node 不执行全仓库 prune,也不增加常驻维护服务。任务标识以 cloud effect ID 幂等;已运行旧 Job 在数据库 lease 失效时不会自动停止。 + +## Substrate 最小接口 + +下面是模拟器实际实现的 HTTP 形式,生产适配器可以映射到其控制 API,但必须保持语义: + +| 方法 | 请求 | 返回与不变量 | +|---|---|---| +| GET `/effects/{effectId}` | cloud 事先分配的 UUID | 404=确知尚无该 intent;否则返回原 request、externalId、state、result,查询不会创建 | +| PUT `/effects/{effectId}` | kind、projectId、workspaceId?、repositoryUrl、requestedRef、sandboxInstanceId? | 首次先落 journal 再执行;同 ID 同 payload 幂等,同 ID 不同 payload 409;成功后原结果保留 | + +kind 支持 storage_ensure、worktree_ensure、sandbox_ensure、sandbox_terminate、worktree_delete、storage_delete。模拟器只接受显式 repository URL→本地 fixture 映射,不接入真实私有 Git 凭据。cloud 内部的 snapshot 才向受控 Controller 提供本 Project 的 credential reference;基础设施负责解析引用、注入 Git 凭据、审计和轮换,Cloud 从不保存密钥值。 + +生产 Substrate 应用独立服务身份与受控网络保护这些接口,并验证 Project/Workspace/effect scope;模拟 HTTP handler 仅供 loopback 测试,不能作为生产公共端点发布。幂等 ensure 必须能查询“执行成功但响应丢失”的实际对象,不能以调用方超时判定不存在。terminate 返回确认旧进程不会再访问存储的证据;不确定就 blocked,不分配新 generation。 + +模拟器是同步有限 Job,持久状态为 running/succeeded/failed;全局 mutex 使重入同任务串行,重新构建 handler 后按 journal 与磁盘/Git 状态协调。真实异步 Job 必须提供查询、终止请求与终止确认,Controller 接管先协调存量 Job,不能把“旧 controller 失租”等同于 Job 已终止。 + +## 阶段二必须实际验证 + +- 单集群跨宿主共享存储需 RWX 与 Git 依赖的锁、原子 rename/文件操作语义;RWO 不能冒充跨节点共享卷。 +- Node 镜像、bare/linked-worktree 的部分挂载路径、Git common-dir/worktree metadata 的容器可移植性。 +- 旧 sandbox/维护 Job 的终止或 storage fence,在网络分区与 Controller 接管下仍阻止旧文件写入。 +- 真实 Node 本地准入/idle 原子性、全部执行类型覆盖、Node 重启和 token 刷新、Agent/Deno/PTY 子进程归属。 +- 长 Job 期间每 10 秒续租和失租立即停止后续调度;未知 prompt 结果不自动重放。 +- Gateway 的华为登录集成、内部凭据签发和生产证书/密钥分发。 + +## 后续业务数据迁移 + +Session 仍属于 Workspace,不将 sandboxId/nodeId 作为持久业务身份。完整 Session/Workflow Run、Effect、插件配置/安装状态、历史持久化和 desktop 迁移不在本阶段实现。未来表需继承 tenant+owner+Project+Workspace 约束,并由 cloud 保存权威状态;Controller 不得自行建立 PG 旁路。 + +desktop bootstrap 当前耦合 SQLite、Session JSONL、插件与 Workflow,阶段二拆出接口适配。历史 JSONL 追加顺序不等同展示 position;重复 position 表示修正,迁移必须保留最后修正、Gap、受损尾行和 pending tool 语义,不能直接按 append 顺序赋展示 seq。先定义快照/增量边界、幂等导入键与所有权映射,再导入历史;本次未导入任何桌面数据。 + +Effect Scope、Desired State/Generation、插件 canonical identity、Workflow 状态与运行结果必须各自有明确 cloud 持久化归属与版本契约;不要把这些塞进阶段一 operations.result 的任意 JSON 中。当前 Task 仅为 isolated Workspace 一对一展示身份,execution_tickets 是并发准入证据,不是完整 Session/Workflow 领域替代品。 diff --git a/go.mod b/go.mod index 8dbd2a9..360bfe9 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,21 @@ module github.com/wanglongan587/cloud go 1.27.1 require ( + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.12.0 - github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 github.com/spf13/viper v1.21.0 go.uber.org/zap v1.28.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 - gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.2 ) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect - filippo.io/edwards25519 v1.1.0 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Antonboom/errname v1.0.0 // indirect @@ -56,7 +58,6 @@ require ( github.com/daixiang0/gci v0.13.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -66,12 +67,12 @@ require ( github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/ghostiam/protogetter v0.3.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-critic/go-critic v0.12.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -95,7 +96,6 @@ require ( github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect @@ -106,11 +106,15 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jgautheron/goconst v1.7.1 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jjti/go-spancheck v0.6.4 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/julz/importas v0.2.0 // indirect github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect @@ -128,6 +132,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect github.com/macabu/inamedparam v0.1.3 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/maratori/testableexamples v1.0.0 // indirect github.com/maratori/testpackage v1.1.1 // indirect github.com/matoous/godox v1.1.0 // indirect @@ -139,13 +144,17 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/moricho/tparallel v0.3.2 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polyfloyd/go-errorlint v1.7.1 // indirect github.com/prometheus/client_golang v1.12.1 // indirect @@ -160,7 +169,6 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/ryancurrah/gomodguard v1.3.5 // indirect @@ -198,6 +206,7 @@ require ( github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.3.1 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xen0n/gosmopolitan v1.2.2 // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect @@ -219,14 +228,12 @@ require ( golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.0 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect mvdan.cc/gofumpt v0.12.0 // indirect mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect ) diff --git a/go.sum b/go.sum index 38a8f31..8c7a120 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,6 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= @@ -61,8 +59,12 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/OpenPeeDeeP/depguard/v2 v2.2.0 h1:vDfG60vDtIuf0MEOhmLlLLSzqaRM8EMcgJPdp74zmpA= github.com/OpenPeeDeeP/depguard/v2 v2.2.0/go.mod h1:CIzddKRvLBC4Au5aYP/i3nyaWQ+ClszLIuVocRiCYFQ= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -136,8 +138,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -158,16 +160,14 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -179,6 +179,12 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -187,9 +193,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= @@ -202,6 +212,8 @@ github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsO github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= @@ -220,6 +232,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -291,12 +305,9 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -314,8 +325,12 @@ github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXS github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -328,6 +343,14 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= github.com/jgautheron/goconst v1.7.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= @@ -338,6 +361,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -391,12 +416,15 @@ github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84Yrj github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -420,6 +448,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -432,15 +462,27 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -448,6 +490,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L5TVMCnA= github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= @@ -486,17 +530,10 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -570,7 +607,9 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= @@ -594,6 +633,8 @@ github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYR github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= @@ -611,6 +652,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM= go-simpler.org/sloglint v0.9.0 h1:/40NQtjRx9txvsB/RN022KsUJU+zaaSb/9q9BSefSrE= @@ -645,8 +688,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -660,6 +701,7 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= @@ -693,8 +735,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -737,8 +777,6 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -762,8 +800,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -817,8 +853,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= @@ -843,8 +877,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -906,10 +938,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1010,8 +1044,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= -gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= @@ -1025,16 +1059,6 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.0 h1:TAODvD3knlq75WCp2nyGJtT4LeRV/o7NN9nYPeVJXf8= honnef.co/go/tools v0.6.0/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= -mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= -mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho= mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA= mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..cd35b39 --- /dev/null +++ b/integration/README.md @@ -0,0 +1,37 @@ +# integration: PostgreSQL Integration Test Suite + +`integration` houses the automated integration test suite for Ora Cloud. It exercises the complete server stack across real boundaries: real HTTP requests, authoritative PostgreSQL database constraints, real Git repository operations, and filesystem I/O. + +## Test categories and coverage + +- **`cloud_test.go`**: End-to-end lifecycle verification: + - Project creation, storage allocation, and Git bare repository cloning. + - Linked worktree creation and `one_main` constraint enforcement. + - Workspace state progression (`provisioning` $\rightarrow$ `ready` $\rightarrow$ `stopped` $\rightarrow$ `deleted`). + - Controller leasing, epoch fencing, and operation claiming/advancing. + - Idempotency replay and conflict detection. +- **`endpoints_test.go`**: Comprehensive route and parameter matrix testing for all public and internal control endpoints. +- **`contract_test.go`**: End-to-end OpenAPI contract validation against live HTTP responses. +- **`security_test.go`**: Authentication, authorization, and isolation tests: + - Tenant boundary isolation and cross-tenant data leak prevention. + - Role-based access control (admin vs member permissions). + - Two-tier credential validation and `service.Subject == user.Caller` binding checks. + +## Hermetic testing invariants + +- **Schema isolation**: Every test executes within an isolated, dynamically provisioned PostgreSQL schema (`CREATE SCHEMA `). Schemas are completely destroyed in `t.Cleanup` (`DROP SCHEMA CASCADE`), guaranteeing tests do not share mutable database state. +- **Mandatory PostgreSQL**: When running under CI (`REQUIRE_POSTGRES=1`), tests fail immediately if `TEST_DATABASE_URL` is unset rather than silently skipping. +- **Parallel execution & race detection**: Tests are designed to run safely with `go test -race` under `task test:race` to verify concurrency invariants and lock ordering. + +## Running integration tests + +```powershell +# Local Windows PostgreSQL running via scripts/postgres.ps1: +$env:TEST_DATABASE_URL='host=127.0.0.1 port=55432 user=postgres dbname=ora_test sslmode=disable' +task test:integration + +# Full test gate with race detector: +task test:race +``` + +See [Local setup](../README.md#本地验证), [Core contract](../docs/core-contract.md), and [Taskfile.yml](../Taskfile.yml). diff --git a/integration/cloud_test.go b/integration/cloud_test.go new file mode 100644 index 0000000..809b1e6 --- /dev/null +++ b/integration/cloud_test.go @@ -0,0 +1,506 @@ +// Package integration exercises real HTTP, PostgreSQL constraints, durable effects, and Git. +package integration + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "go.uber.org/zap" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wanglongan587/cloud/internal/api/router" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" +) + +type fixture struct { + t *testing.T + store *core.Store + client *simulator.Client + controller *simulator.Controller + substrate *simulator.Substrate + user core.Claims + tid, uid, root, commit string + cloud *httptest.Server + external *httptest.Server + pgConfig *pgx.ConnConfig +} + +func setup(t *testing.T) *fixture { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + if os.Getenv("REQUIRE_POSTGRES") == "1" { + t.Fatal("TEST_DATABASE_URL is required; PostgreSQL integration must not skip") + } + t.Skip("real PostgreSQL: set TEST_DATABASE_URL (task test:integration requires it)") + } + config, e := pgx.ParseConfig(dsn) + must(t, e) + admin := stdlib.OpenDB(*config) + must(t, admin.Ping()) + schema := "test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, e = admin.Exec("CREATE SCHEMA " + schema) + must(t, e) + config.RuntimeParams["search_path"] = schema + pool := stdlib.OpenDB(*config) + db, e := gorm.Open(postgres.New(postgres.Config{Conn: pool}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + must(t, e) + store, e := core.NewStore(db) + must(t, e) + t.Cleanup(func() { + pool.Close() + _, err := admin.Exec("DROP SCHEMA " + schema + " CASCADE") + if err != nil { + t.Error(err) + } + admin.Close() + }) + must(t, store.Migrate(context.Background())) + must(t, store.Migrate(context.Background())) + credentials, e := simulator.NewCredentials() + must(t, e) + auth, e := core.NewAuthenticator("ora-cloud", credentials.Trust) + must(t, e) + gin.SetMode(gin.TestMode) + log, _ := zap.NewDevelopment() + cloud := httptest.NewServer(router.New(store, auth, log)) + t.Cleanup(cloud.Close) + // Git for Windows still limits the linked-worktree GIT_DIR even with core.longpaths. + root, e := os.MkdirTemp("", "ora-cloud-") + must(t, e) + t.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + t.Error(err) + } + }) + repo := filepath.Join(root, "source") + must(t, os.MkdirAll(repo, 0o700)) + runGit(t, "init", "--initial-branch=main", repo) + runGit(t, "-C", repo, "config", "user.name", "Integration") + runGit(t, "-C", repo, "config", "user.email", "integration@example.invalid") + must(t, os.WriteFile(filepath.Join(repo, "README.md"), []byte("durable workspace data\n"), 0o600)) + runGit(t, "-C", repo, "add", ".") + runGit(t, "-C", repo, "commit", "-m", "fixture") + commit := runGit(t, "-C", repo, "rev-parse", "HEAD") + substrate, e := simulator.NewSubstrate(filepath.Join(root, "substrate"), map[string]string{"https://example.invalid/repo.git": repo}) + must(t, e) + external := httptest.NewServer(substrate) + t.Cleanup(external.Close) + client := &simulator.Client{URL: cloud.URL, Credentials: credentials, HTTP: &http.Client{Timeout: 10 * time.Second}, Subject: "controller-a"} + f := &fixture{t: t, store: store, client: client, substrate: substrate, root: root, commit: commit, cloud: cloud, user: core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "alice"}, Source: "corp", DisplayName: "Alice"}} + bootstrap, e := store.Bootstrap(context.Background(), "Test tenant", "corp", "alice", "Alice") + must(t, e) + f.tid, f.uid = bootstrap.S("tenantId"), bootstrap.S("userId") + f.controller = &simulator.Controller{Client: client, SubstrateURL: external.URL} + f.external, f.pgConfig = external, config + validateHTTP(t, f) + must(t, f.controller.Acquire(context.Background())) + return f +} + +func TestMigrateUpgradesPreviousSchemaAndData(t *testing.T) { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + if os.Getenv("REQUIRE_POSTGRES") == "1" { + t.Fatal("TEST_DATABASE_URL is required; PostgreSQL integration must not skip") + } + t.Skip("real PostgreSQL: set TEST_DATABASE_URL (task test:integration requires it)") + } + config, err := pgx.ParseConfig(dsn) + must(t, err) + admin := stdlib.OpenDB(*config) + must(t, admin.Ping()) + schema := "test_upgrade_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, err = admin.Exec("CREATE SCHEMA " + schema) + must(t, err) + config.RuntimeParams["search_path"] = schema + pool := stdlib.OpenDB(*config) + t.Cleanup(func() { + must(t, pool.Close()) + _, dropErr := admin.Exec("DROP SCHEMA " + schema + " CASCADE") + must(t, dropErr) + must(t, admin.Close()) + }) + + _, err = pool.Exec("CREATE TABLE schema_migrations(version text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())") + must(t, err) + for _, version := range []string{"0001_core.sql", "0002_aggregate_guards.sql", "0003_resource_versions.sql"} { + migration, readErr := os.ReadFile(filepath.Join("..", "internal", "core", "migrations", version)) + must(t, readErr) + _, err = pool.Exec(string(migration)) + must(t, err) + sum := sha256.Sum256(migration) + _, err = pool.Exec("INSERT INTO schema_migrations(version,checksum) VALUES($1,$2)", version, hex.EncodeToString(sum[:])) + must(t, err) + } + + ids := make([]string, 9) + for i := range ids { + ids[i] = uuid.NewString() + } + tx, err := pool.Begin() + must(t, err) + seed := []struct { + query string + args []any + }{ + {"INSERT INTO users(id,display_name,status) VALUES($1,'Upgrade user','active')", []any{ids[0]}}, + {"INSERT INTO tenants(id,name,status) VALUES($1,'Upgrade tenant','active')", []any{ids[1]}}, + {"INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')", []any{ids[1], ids[0]}}, + {"INSERT INTO projects(id,tenant_id,owner_user_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,'Upgrade project','https://example.invalid/upgrade.git','main','active')", []any{ids[2], ids[1], ids[0]}}, + {"INSERT INTO project_storage(project_id,substrate_storage_id,observed_state) VALUES($1,'upgrade-storage','ready')", []any{ids[2]}}, + {"INSERT INTO workspaces(id,tenant_id,owner_user_id,project_id,kind,desired_state,observed_state,runtime_generation) VALUES($1,$2,$3,$4,'main','running','ready',1)", []any{ids[3], ids[1], ids[0], ids[2]}}, + {"INSERT INTO sandbox_instances(id,workspace_id,generation,substrate_sandbox_id,observed_state) VALUES($1,$2,1,'upgrade-sandbox','running')", []any{ids[4], ids[3]}}, + {"INSERT INTO node_instances(id,sandbox_instance_id,workspace_id,service_subject,connection_state,protocol_version,initialized) VALUES($1,$2,$3,'upgrade-node','connected',1,true)", []any{ids[5], ids[4], ids[3]}}, + {"INSERT INTO execution_tickets(id,workspace_id,node_instance_id,actor_user_id,admission_epoch,kind,state) VALUES($1,$2,$3,$4,1,'interaction','active')", []any{ids[6], ids[3], ids[5], ids[0]}}, + {"INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,'start','succeeded','done','{}','upgrade-operation','upgrade-hash')", []any{ids[7], ids[1], ids[0], ids[2], ids[3]}}, + {"INSERT INTO external_effects(id,operation_id,project_id,kind,state,reconciled_epoch) VALUES($1,$2,$3,'storage_ensure','succeeded',1)", []any{ids[8], ids[7], ids[2]}}, + } + for _, statement := range seed { + _, err = tx.Exec(statement.query, statement.args...) + must(t, err) + } + must(t, tx.Commit()) + + store := &core.Store{Pool: pool} + must(t, store.Migrate(context.Background())) + must(t, store.CheckSchema(context.Background())) + var request []byte + must(t, pool.QueryRow("SELECT request FROM external_effects WHERE id=$1", ids[8]).Scan(&request)) + var intent core.Object + must(t, json.Unmarshal(request, &intent)) + if intent.S("kind") != "storage_ensure" || intent.S("projectId") != ids[2] { + t.Fatalf("external effect intent was not backfilled: %v", intent) + } + var tenantID string + must(t, pool.QueryRow("SELECT tenant_id::text FROM execution_tickets WHERE id=$1", ids[6]).Scan(&tenantID)) + if tenantID != ids[1] { + t.Fatalf("ticket tenant was not backfilled: want %s got %s", ids[1], tenantID) + } +} + +func must(t *testing.T, e error) { + t.Helper() + if e != nil { + t.Fatal(e) + } +} + +func runGit(t *testing.T, args ...string) string { + t.Helper() + b, e := exec.Command("git", args...).CombinedOutput() + if e != nil { + t.Fatalf("git %v: %v %s", args, e, b) + } + return strings.TrimSpace(string(b)) +} + +func (f *fixture) call(method, path string, body core.Object, key string, want int) core.Object { + f.t.Helper() + o, status, e := f.client.Call(context.Background(), method, path, "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, key, body) + must(f.t, e) + if status != want { + f.t.Fatalf("%s %s: want %d got %d %v", method, path, want, status, o) + } + return o +} +func (f *fixture) path(s string) string { return "/api/v1/tenants/" + f.tid + s } +func (f *fixture) create(key string) core.Object { + return f.call("POST", f.path("/projects"), core.Object{"name": "Project", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main"}, key, 202) +} +func (f *fixture) drain() { f.t.Helper(); must(f.t, f.controller.Drain(context.Background())) } +func (f *fixture) ws(wid string) core.Object { + return f.call("GET", f.path("/workspaces/"+wid), nil, "", 200) +} + +func (f *fixture) scalar(q string, args ...any) int { + f.t.Helper() + var n int + must(f.t, f.store.Pool.QueryRow(q, args...).Scan(&n)) + return n +} + +func (f *fixture) node(wid string) core.Claims { + f.t.Helper() + var nid, sid string + var generation int64 + must(f.t, f.store.Pool.QueryRow("SELECT n.id,s.id,s.generation FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id WHERE s.workspace_id=$1 AND s.terminated_at IS NULL AND n.ended_at IS NULL", wid).Scan(&nid, &sid, &generation)) + return core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: nid}, WorkspaceID: wid, SandboxID: sid, Generation: generation} +} + +func (f *fixture) finishTicket(ticketID string, node core.Claims) core.Object { + f.t.Helper() + var ticketVersion int64 + must(f.t, f.store.Pool.QueryRow("SELECT version FROM execution_tickets WHERE id=$1", ticketID).Scan(&ticketVersion)) + out, status, err := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/tickets/"+ticketID+"/finish", "node", node, nil, "", core.Object{"version": ticketVersion}) + must(f.t, err) + if status != 200 { + f.t.Fatalf("finish ticket: want 200 got %d: %v", status, out) + } + return out +} + +func (f *fixture) internal(path string, body core.Object, want int) core.Object { + f.t.Helper() + o, status, e := f.client.Call(context.Background(), "POST", path, "controller", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: f.client.Subject}}, &f.user, "", body) + must(f.t, e) + if status != want { + f.t.Fatalf("%s want %d got %d: %v", path, want, status, o) + } + return o +} + +func TestHTTPProjectLifecycleAndDurableRecovery(t *testing.T) { + f := setup(t) + created := f.create("create") + pid, wid, oid := created.O("resource").S("id"), created.O("workspace").S("id"), created.O("operation").S("id") + duplicate := f.create("create") + if duplicate.O("resource").S("id") != pid { + t.Fatal("idempotency created another project") + } + f.call("POST", f.path("/projects"), core.Object{"name": "Different", "repositoryUrl": "https://example.invalid/repo.git"}, "create", 409) + f.substrate.SetFault("worktree_ensure", "lose_response") + if e := f.controller.Drain(context.Background()); e == nil { + t.Fatal("expected lost external response") + } + if f.scalar("SELECT count(*) FROM external_effects WHERE operation_id=$1", oid) != 2 { + t.Fatal("write-ahead effects missing") + } + f.substrate.SetFault("worktree_ensure", "") + deferred := f.call("GET", f.path("/operations/"+oid), nil, "", 200) + if deferred.S("state") != "retry_wait" || deferred.S("errorCode") != "substrate_timeout" { + t.Fatal("response loss was not deferred", deferred) + } + f.call("POST", f.path("/operations/"+oid+"/retry"), core.Object{"version": deferred.N("version")}, "worktree-retry", 202) + f.drain() + ready := f.ws(wid) + if ready.S("observedState") != "ready" { + t.Fatal(ready) + } + checkout := filepath.Join(f.substrate.Root, "projects", pid, "workspaces", wid, "checkout") + if runGit(t, "-C", checkout, "rev-parse", "HEAD") != f.commit { + t.Fatal("real commit not resolved") + } + if !strings.Contains(runGit(t, "--git-dir", filepath.Join(f.substrate.Root, "projects", pid, "repository.git"), "worktree", "list", "--porcelain"), wid) { + t.Fatal("main is not linked worktree") + } + isolated := f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "Task", "baseRef": "main"}, "isolated", 202) + iwid := isolated.O("resource").S("id") + f.drain() + if f.scalar("SELECT count(*) FROM tasks WHERE workspace_id=$1", iwid) != 1 { + t.Fatal("task display identity missing") + } + f.call("DELETE", f.path("/workspaces/"+wid), core.Object{"version": f.ws(wid).N("version")}, "main-delete", 409) + data := filepath.Join(f.substrate.Root, "projects", pid, "workspaces", iwid, "runtime", "state.txt") + must(t, os.WriteFile(data, []byte("persistent"), 0o600)) + oldNode := f.node(iwid) + stop := f.call("POST", f.path("/workspaces/"+iwid+"/stop"), core.Object{"version": f.ws(iwid).N("version")}, "stop", 202) + f.drain() + if f.ws(iwid).S("observedState") != "stopped" { + t.Fatal("not stopped") + } + if _, e := os.Stat(data); e != nil { + t.Fatal("stop deleted persistent data") + } + // Same key is checked before the now-stale version. + retryStop := f.call("POST", f.path("/workspaces/"+iwid+"/stop"), core.Object{"version": isolated.O("resource").N("version") + 3}, "different-stop", 409) + _ = retryStop + if stop.O("operation").S("id") == "" { + t.Fatal(stop) + } + f.call("POST", f.path("/workspaces/"+iwid+"/start"), core.Object{"version": f.ws(iwid).N("version")}, "restart", 202) + f.drain() + if f.ws(iwid).N("runtimeGeneration") != 2 { + t.Fatal("generation not advanced") + } + if _, e := os.Stat(data); e != nil { + t.Fatal("replacement lost data") + } + _, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/status", "node", oldNode, nil, "", core.Object{"version": 1, "connectionState": "connected", "initialized": true}) + must(t, e) + if status != 409 { + t.Fatal("late old Node accepted", status) + } + del := f.call("DELETE", f.path("/workspaces/"+iwid), core.Object{"version": f.ws(iwid).N("version")}, "delete-isolated", 202) + f.substrate.SetFault("worktree_delete", "fail") + if e = f.controller.Drain(context.Background()); e == nil { + t.Fatal("expected cleanup failure") + } + op := f.call("GET", f.path("/operations/"+del.O("operation").S("id")), nil, "", 200) + if op.S("state") != "retry_wait" || op.S("errorCode") != "git_cleanup_failed" { + t.Fatal("cleanup failure was not deferred", op) + } + if _, e = os.Stat(data); e != nil { + t.Fatal("failed cleanup lost tracking/data") + } + f.substrate.SetFault("worktree_delete", "") + f.call("POST", f.path("/operations/"+op.S("id")+"/retry"), core.Object{"version": op.N("version")}, "isolated-cleanup-retry", 202) + f.drain() + f.call("GET", f.path("/workspaces/"+iwid), nil, "", 404) + p := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + f.call("DELETE", f.path("/projects/"+pid), core.Object{"version": p.N("version")}, "delete-project", 202) + f.drain() + f.call("GET", f.path("/projects/"+pid), nil, "", 404) + if _, e = os.Stat(filepath.Join(f.substrate.Root, "projects", pid)); !os.IsNotExist(e) { + t.Fatal("project storage survived delete", e) + } +} + +func TestIdentityConcurrencyMembershipAndIsolation(t *testing.T) { + f := setup(t) + f.user.Subject = "new-user" + var wg sync.WaitGroup + ids := make(chan string, 20) + errs := make(chan string, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + o, status, e := f.client.Call(context.Background(), "GET", "/api/v1/me", "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, "", nil) + if e != nil || status != 200 { + errs <- fmt.Sprint(status, e) + return + } + ids <- o.S("id") + }() + } + wg.Wait() + close(ids) + close(errs) + for e := range errs { + t.Error(e) + } + id := "" + for got := range ids { + if id == "" { + id = got + } + if id != got { + t.Fatal("duplicate identity users") + } + } + if f.scalar("SELECT count(*) FROM users") != 2 { + t.Fatal("orphan users from login race") + } + f.call("POST", f.path("/projects"), core.Object{"name": "Denied", "repositoryUrl": "https://example.invalid/repo.git"}, "no-member", 403) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+id), core.Object{"role": "member", "status": "active", "version": 0}, "", 200) + created := f.create("owner-project") + f.drain() + pid, wid := created.O("resource").S("id"), created.O("workspace").S("id") + f.user.Subject = "new-user" + f.call("GET", f.path("/projects/"+pid), nil, "", 404) + f.call("GET", f.path("/workspaces/"+wid), nil, "", 404) + f.call("GET", f.path("/operations/"+created.O("operation").S("id")), nil, "", 404) + list := f.call("GET", f.path("/projects"), nil, "", 200) + if len(list["items"].([]any)) != 0 { + t.Fatal("list owner filter missing") + } + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+id), core.Object{"role": "admin", "status": "active", "version": 1}, "", 200) + f.user.Subject = "new-user" + statusView := f.call("GET", f.path("/resource-status"), nil, "", 200) + encoded, _ := json.Marshal(statusView) + if bytes.Contains(encoded, []byte("repository")) || bytes.Contains(encoded, []byte("secret")) || bytes.Contains(encoded, []byte("result")) { + t.Fatal("admin view leaks", string(encoded)) + } + adminStop := f.call("POST", f.path("/workspaces/"+wid+"/administrative-stop"), core.Object{"version": f.scalar("SELECT version FROM workspaces WHERE id=$1", wid)}, "admin-stop", 202) + f.drain() + adminOp := f.call("GET", f.path("/operations/"+adminStop.O("operation").S("id")), nil, "", 200) + for _, o := range []core.Object{adminStop.O("operation"), adminOp} { + if _, exists := o["request"]; exists { + t.Fatal("admin operation leaks request") + } + if _, exists := o["result"]; exists { + t.Fatal("admin operation leaks result") + } + } + other, e := f.store.Bootstrap(context.Background(), "Other", "corp", "other", "Other") + must(t, e) + original := f.tid + f.tid = other.S("tenantId") + f.call("GET", f.path("/projects/"+pid), nil, "", 403) + f.tid = original +} + +func TestStopAdmissionRaceAndIdleEvidence(t *testing.T) { + f := setup(t) + created := f.create("project") + f.drain() + wid := created.O("workspace").S("id") + node := f.node(wid) + ticketID := uuid.NewString() + body := core.Object{"tenantId": f.tid, "workspaceId": wid, "action": "execute", "kind": "interaction", "ticketId": ticketID, "epoch": f.controller.Epoch} + f.internal("/internal/v1/access", core.Object{"tenantId": f.tid, "workspaceId": wid, "action": "execute", "epoch": f.controller.Epoch}, 200) + ticket := f.internal("/internal/v1/admissions", body, 200) + f.call("POST", f.path("/workspaces/"+wid+"/stop"), core.Object{"version": f.ws(wid).N("version")}, "busy-stop", 409) + if !f.ws(wid).B("admissionOpen") { + t.Fatal("rejected stop closed admission") + } + _, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/tickets/"+ticketID+"/finish", "node", node, nil, "", core.Object{"version": ticket.N("version") + 1}) + must(t, e) + if status != 409 { + t.Fatal("stale ticket version accepted", status) + } + finished := f.finishTicket(ticketID, node) + replay, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/tickets/"+ticketID+"/finish", "node", node, nil, "", core.Object{"version": ticket.N("version")}) + must(t, e) + if status != 200 || replay.N("version") != finished.N("version") { + t.Fatal("finished ticket replay was not idempotent", status, replay) + } + version := f.ws(wid).N("version") + var stopStatus, admitStatus int + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, stopStatus, _ = f.client.Call(context.Background(), "POST", f.path("/workspaces/"+wid+"/stop"), "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, "race-stop", core.Object{"version": version}) + }() + body["ticketId"] = uuid.NewString() + go func() { + defer wg.Done() + _, admitStatus, _ = f.client.Call(context.Background(), "POST", "/internal/v1/admissions", "controller", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: f.client.Subject}}, &f.user, "", body) + }() + wg.Wait() + if (stopStatus != 202 || admitStatus != 409) && (stopStatus != 409 || admitStatus != 200) { + t.Fatalf("unsafe race outcomes stop=%d admission=%d", stopStatus, admitStatus) + } + if admitStatus == 200 { + f.finishTicket(body.S("ticketId"), node) + f.call("POST", f.path("/workspaces/"+wid+"/stop"), core.Object{"version": f.ws(wid).N("version")}, "final-stop", 202) + } + claimed := f.internal("/internal/v1/operations/claim", core.Object{"epoch": f.controller.Epoch}, 200).O("operation") + f.internal("/internal/v1/operations/"+claimed.S("id")+"/advance", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version")}, 409) + var nv int64 + must(t, f.store.Pool.QueryRow("SELECT version FROM node_instances WHERE id=$1", node.Subject).Scan(&nv)) + _, status, e = f.client.Call(context.Background(), "POST", "/internal/v1/nodes/idle", "node", node, nil, "", core.Object{"version": nv, "admissionEpoch": f.ws(wid).N("admissionEpoch"), "operationId": claimed.S("id"), "idle": false}) + must(t, e) + if status != 200 { + t.Fatal(status) + } + if !f.ws(wid).B("admissionOpen") { + t.Fatal("node refusal failed to restore admission") + } +} diff --git a/integration/contract_test.go b/integration/contract_test.go new file mode 100644 index 0000000..3ca9c3e --- /dev/null +++ b/integration/contract_test.go @@ -0,0 +1,145 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/legacy" + "github.com/jackc/pgx/v5/stdlib" + "go.uber.org/zap" + + "github.com/wanglongan587/cloud/internal/api/router" + "github.com/wanglongan587/cloud/internal/contract" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" +) + +type validatingTransport struct { + base http.RoundTripper + cloudURL string + routes routers.Router +} + +func (v *validatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + res, e := v.base.RoundTrip(req) + if e != nil || !strings.HasPrefix(req.URL.String(), v.cloudURL) { + return res, e + } + body, e := io.ReadAll(res.Body) + res.Body.Close() + if e != nil { + return nil, e + } + res.Body = io.NopCloser(bytes.NewReader(body)) + route, params, e := v.routes.FindRoute(req) + if e != nil { + return nil, e + } + input := &openapi3filter.ResponseValidationInput{RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route}, Status: res.StatusCode, Header: res.Header, Options: &openapi3filter.Options{IncludeResponseStatus: true}} + input.SetBodyBytes(body) + if e = openapi3filter.ValidateResponse(req.Context(), input); e != nil { + res.Body.Close() + return nil, fmt.Errorf("OpenAPI response mismatch %s %s: %w", req.Method, req.URL.Path, e) + } + return res, nil +} + +func validateHTTP(t *testing.T, f *fixture) { + t.Helper() + b, e := json.Marshal(contract.Document()) + must(t, e) + doc, e := openapi3.NewLoader().LoadFromData(b) + must(t, e) + doc.Servers = nil + routes, e := legacy.NewRouter(doc) + must(t, e) + f.client.HTTP.Transport = &validatingTransport{base: http.DefaultTransport, cloudURL: f.cloud.URL, routes: routes} +} + +func TestRestartRecreatesCloudSubstrateAndController(t *testing.T) { + f := setup(t) + validateHTTP(t, f) + created := f.create("durable-create") + wid := created.O("workspace").S("id") + f.substrate.SetFault("sandbox_ensure", "lose_response") + if e := f.controller.Drain(context.Background()); e == nil { + t.Fatal("expected lost sandbox response") + } + // Rebuild every service object from PostgreSQL and the same filesystem, discard controller memory. + f.cloud.Close() + f.external.Close() + must(t, f.store.Pool.Close()) + auth, e := core.NewAuthenticator("ora-cloud", f.client.Credentials.Trust) + must(t, e) + reopened := &core.Store{Pool: stdlib.OpenDB(*f.pgConfig)} + t.Cleanup(func() { reopened.Pool.Close() }) + must(t, reopened.CheckSchema(context.Background())) + f.store = reopened + cloud := httptest.NewServer(router.New(reopened, auth, zap.NewNop())) + t.Cleanup(cloud.Close) + substrate, e := simulator.NewSubstrate(f.substrate.Root, f.substrate.Repositories) + must(t, e) + external := httptest.NewServer(substrate) + t.Cleanup(external.Close) + client := &simulator.Client{URL: cloud.URL, Credentials: f.client.Credentials, HTTP: &http.Client{}, Subject: "controller-after-restart"} + f.client = client + f.cloud = cloud + validateHTTP(t, f) + _, e = f.store.Pool.Exec("UPDATE controller_leases SET expires_at=clock_timestamp()-interval '1 second'") + must(t, e) + _, e = f.store.Pool.Exec("UPDATE operations SET retry_at=clock_timestamp()-interval '1 second' WHERE id=$1", created.O("operation").S("id")) + must(t, e) + controller := &simulator.Controller{Client: client, SubstrateURL: external.URL} + must(t, controller.Acquire(context.Background())) + must(t, controller.Drain(context.Background())) + if f.ws(wid).S("observedState") != "ready" || f.scalar("SELECT count(*) FROM sandbox_instances WHERE workspace_id=$1", wid) != 1 || f.scalar("SELECT count(*) FROM external_effects WHERE workspace_id=$1 AND kind='worktree_ensure'", wid) != 1 { + t.Fatal("restart depended on discarded service memory") + } + replay := f.create("durable-create") + if replay.O("workspace").S("id") != wid { + t.Fatal("cloud restart lost idempotency record") + } +} + +func TestSameHolderRestartsRunningOperationFromPersistedEffectIntent(t *testing.T) { + f := setup(t) + created := f.create("same-holder-restart") + operationID := created.O("operation").S("id") + claimed := f.internal("/internal/v1/operations/claim", core.Object{"epoch": f.controller.Epoch}, 200).O("operation") + planned := f.internal("/internal/v1/operations/"+operationID+"/effects", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version"), "kind": "storage_ensure"}, 200) + request := planned.O("effect").O("request") + if request.S("kind") != "storage_ensure" || request.S("projectId") != created.O("resource").S("id") { + t.Fatal("effect intent was not persisted", request) + } + + restarted := &simulator.Controller{Client: f.client, SubstrateURL: f.external.URL} + must(t, restarted.Acquire(context.Background())) + if restarted.Epoch != f.controller.Epoch { + t.Fatal("same holder unexpectedly changed lease epoch") + } + must(t, restarted.Drain(context.Background())) + if f.ws(created.O("workspace").S("id")).S("observedState") != "ready" { + t.Fatal("same-holder restart did not recover running operation") + } + f.internal("/internal/v1/operations/"+operationID+"/advance", core.Object{"epoch": f.controller.Epoch, "version": planned.O("operation").N("version")}, 409) +} + +func TestSchemaCheckRejectsUnknownMigration(t *testing.T) { + f := setup(t) + _, err := f.store.Pool.Exec("INSERT INTO schema_migrations(version,checksum) VALUES('9999_future.sql','future')") + must(t, err) + err = f.store.CheckSchema(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unknown to this binary") { + t.Fatal("older binary accepted a newer schema", err) + } +} diff --git a/integration/endpoints_test.go b/integration/endpoints_test.go new file mode 100644 index 0000000..dfd6a34 --- /dev/null +++ b/integration/endpoints_test.go @@ -0,0 +1,201 @@ +package integration + +import ( + "context" + "net/http" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" +) + +func TestRemainingPublicContractsAndMembershipRevocation(t *testing.T) { + f := setup(t) + health, e := f.client.HTTP.Get(f.cloud.URL + "/healthz") + must(t, e) + health.Body.Close() + if health.StatusCode != http.StatusOK { + t.Fatal("health unavailable") + } + tenants := f.call("GET", "/api/v1/me/tenants?limit=10", nil, "", 200) + if len(tenants["items"].([]any)) != 1 { + t.Fatal("tenant membership listing", tenants) + } + f.call("GET", f.path("/members?limit=10"), nil, "", 200) + p := f.create("project") + f.drain() + pid, wid := p.O("resource").S("id"), p.O("workspace").S("id") + before := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + renamed := f.call("PATCH", f.path("/projects/"+pid), core.Object{"version": before.N("version"), "name": "Renamed"}, "", 200) + if renamed.S("name") != "Renamed" || renamed.N("version") != before.N("version")+1 { + t.Fatal("rename version", renamed) + } + f.call("PATCH", f.path("/projects/"+pid), core.Object{"version": before.N("version"), "name": "Stale"}, "", 409) + list := f.call("GET", f.path("/projects/"+pid+"/workspaces"), nil, "", 200) + if len(list["items"].([]any)) != 1 { + t.Fatal(list) + } + f.internal("/internal/v1/access", core.Object{"tenantId": f.tid, "workspaceId": wid, "action": "read"}, 200) + f.user.Subject = "member" + member := f.call("GET", "/api/v1/me", nil, "", 200) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+member.S("id")), core.Object{"role": "member", "status": "active", "version": 0}, "", 200) + f.user.Subject = "member" + owned := f.create("member-project") + f.drain() + memberWorkspace := owned.O("workspace").S("id") + f.call("GET", f.path("/resource-status"), nil, "", 403) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+member.S("id")), core.Object{"role": "member", "status": "disabled", "version": 1}, "", 200) + f.user.Subject = "member" + f.call("GET", f.path("/workspaces/"+memberWorkspace), nil, "", 403) + f.internal("/internal/v1/admissions", core.Object{"tenantId": f.tid, "workspaceId": memberWorkspace, "action": "execute", "ticketId": uuid.NewString(), "kind": "task", "epoch": f.controller.Epoch}, 403) + if f.scalar("SELECT count(*) FROM workspaces WHERE id=$1 AND owner_user_id=$2 AND deleted_at IS NULL", memberWorkspace, member.S("id")) != 1 { + t.Fatal("revocation destroyed ownership") + } +} + +func TestOperationPreconditionsAndScheduledRetry(t *testing.T) { + f := setup(t) + p := f.create("create") + claimed := f.internal("/internal/v1/operations/claim", core.Object{"epoch": f.controller.Epoch}, 200).O("operation") + oid := claimed.S("id") + prefix := "/internal/v1/operations/" + oid + f.internal(prefix+"/advance", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version")}, 409) + f.internal(prefix+"/advance", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version"), "state": "succeeded"}, 400) + f.internal(prefix+"/effects", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version"), "kind": "sandbox_ensure", "workspaceId": p.O("workspace").S("id")}, 409) + effect := f.internal(prefix+"/effects", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version"), "kind": "storage_ensure"}, 200) + f.internal(prefix+"/advance", core.Object{"epoch": f.controller.Epoch, "version": claimed.N("version")}, 409) + deferred := f.internal(prefix+"/defer", core.Object{"epoch": f.controller.Epoch, "version": effect.O("operation").N("version"), "state": "retry_wait", "errorCode": "substrate_timeout", "retrySeconds": 30}, 200) + none := f.internal("/internal/v1/operations/claim", core.Object{"epoch": f.controller.Epoch}, 200) + if none["operation"] != nil { + t.Fatal("retry claimed before due") + } + _, e := f.store.Pool.Exec("UPDATE operations SET retry_at=clock_timestamp()-interval '1 second' WHERE id=$1", oid) + must(t, e) + f.drain() + done := f.call("GET", f.path("/operations/"+oid), nil, "", 200) + if done.S("state") != "succeeded" || done.N("version") <= deferred.N("version") { + t.Fatal("scheduled retry failed", done) + } + f.internal(prefix+"/effects/"+effect.O("effect").S("id")+"/result", core.Object{"epoch": f.controller.Epoch, "version": effect.O("operation").N("version"), "state": "succeeded", "externalId": "forged", "result": core.Object{"layoutVersion": 1}}, 409) + node := f.node(p.O("workspace").S("id")) + out, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/register", "node", node, nil, "", core.Object{"protocolVersion": 1}) + must(t, e) + if status != 200 { + t.Fatal(out) + } + _, status, e = f.client.Call(context.Background(), "POST", "/internal/v1/nodes/status", "node", node, nil, "", core.Object{"version": out.N("version"), "connectionState": "connected"}) + must(t, e) + if status != 400 { + t.Fatal("missing initialized accepted", status) + } + different := node + different.Subject = uuid.NewString() + _, status, e = f.client.Call(context.Background(), "POST", "/internal/v1/nodes/register", "node", different, nil, "", core.Object{"protocolVersion": 1}) + must(t, e) + if status != 409 { + t.Fatal("live node overwritten", status) + } + _, status, e = f.client.Call(context.Background(), "POST", "/internal/v1/nodes/register", "node", node, nil, "", core.Object{"protocolVersion": 2}) + must(t, e) + if status != 400 { + t.Fatal("unsupported protocol accepted", status) + } + f.internal("/internal/v1/controller-lease/release", core.Object{"epoch": f.controller.Epoch}, 200) + f.internal("/internal/v1/controller-lease/renew", core.Object{"epoch": f.controller.Epoch}, 409) +} + +func TestDatabaseEffectAndTicketScopes(t *testing.T) { + f := setup(t) + a := f.create("a") + b := f.create("b") + f.drain() + aw, bw := a.O("workspace").S("id"), b.O("workspace").S("id") + node := f.node(aw) + if _, e := f.store.Pool.Exec("INSERT INTO execution_tickets(id,tenant_id,workspace_id,node_instance_id,actor_user_id,admission_epoch,kind,state) VALUES($1,$2,$3,$4,$5,0,'task','active')", uuid.NewString(), f.tid, bw, node.Subject, f.uid); e == nil { + t.Fatal("ticket accepted other workspace node") + } + if _, e := f.store.Pool.Exec("INSERT INTO external_effects(id,operation_id,project_id,workspace_id,kind,state,request,reconciled_epoch) VALUES($1,$2,$3,$4,'worktree_delete','planned','{}',1)", uuid.NewString(), a.O("operation").S("id"), b.O("resource").S("id"), bw); e == nil { + t.Fatal("effect operation project mismatch accepted") + } + f.user.Subject = "ticket-bob" + bob := f.call("GET", "/api/v1/me", nil, "", 200) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+bob.S("id")), core.Object{"role": "member", "status": "active", "version": 0}, "", 200) + if _, e := f.store.Pool.Exec("INSERT INTO execution_tickets(id,tenant_id,workspace_id,node_instance_id,actor_user_id,admission_epoch,kind,state) VALUES($1,$2,$3,$4,$5,0,'task','active')", uuid.NewString(), f.tid, aw, node.Subject, bob.S("id")); e == nil { + t.Fatal("ticket accepted an actor who does not own the workspace") + } + // The verified gateway key cannot elevate itself by placing controller in role. + nowClaims := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}, Role: "controller"} + token, err := f.client.Credentials.Token("gateway", nowClaims) + must(t, err) + parsed, _, e := jwt.NewParser().ParseUnverified(token, &core.Claims{}) + must(t, e) + claims, ok := parsed.Claims.(*core.Claims) + if !ok { + t.Fatal("claims type") + } + claims.Role = "controller" + forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + forged.Header["kid"] = "gateway" + raw, e := forged.SignedString(f.client.Credentials.Private["gateway"]) + must(t, e) + req, e := http.NewRequest("POST", f.cloud.URL+"/internal/v1/controller-lease/acquire", nil) + must(t, e) + req.Header.Set("Authorization", "Bearer "+raw) + res, e := f.client.HTTP.Do(req) + must(t, e) + res.Body.Close() + if res.StatusCode != 401 { + t.Fatal("key purpose failed to pin service role", res.StatusCode) + } +} + +func TestProjectDeleteClosesEveryWorkspaceAndWaitsForCleanup(t *testing.T) { + f := setup(t) + p := f.create("create") + f.drain() + pid, main := p.O("resource").S("id"), p.O("workspace").S("id") + side := f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "Side", "baseRef": "main"}, "side", 202) + f.drain() + wid := side.O("resource").S("id") + n := f.node(wid) + ticket := uuid.NewString() + f.internal("/internal/v1/admissions", core.Object{"tenantId": f.tid, "workspaceId": wid, "action": "execute", "ticketId": ticket, "kind": "task", "epoch": f.controller.Epoch}, 200) + project := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + f.call("DELETE", f.path("/projects/"+pid), core.Object{"version": project.N("version")}, "busy-delete", 409) + if !f.ws(main).B("admissionOpen") || !f.ws(wid).B("admissionOpen") { + t.Fatal("busy cascade partially closed admission") + } + f.finishTicket(ticket, n) + deleting := f.call("DELETE", f.path("/projects/"+pid), core.Object{"version": project.N("version")}, "delete", 202) + for _, id := range []string{main, wid} { + f.internal("/internal/v1/admissions", core.Object{"tenantId": f.tid, "workspaceId": id, "action": "execute", "ticketId": uuid.NewString(), "kind": "task", "epoch": f.controller.Epoch}, 409) + } + f.substrate.SetFault("worktree_delete", "fail") + if err := f.controller.Drain(context.Background()); err == nil { + t.Fatal("expected maintenance failure") + } + if f.scalar("SELECT count(*) FROM external_effects WHERE project_id=$1 AND kind='storage_delete'", pid) != 0 { + t.Fatal("storage deletion scheduled before maintenance completed") + } + if f.scalar("SELECT count(*) FROM workspaces WHERE project_id=$1 AND deleted_at IS NULL", pid) != 2 { + t.Fatal("failed cascade lost resources") + } + f.substrate.SetFault("worktree_delete", "") + operation := f.call("GET", f.path("/operations/"+deleting.O("operation").S("id")), nil, "", 200) + if operation.S("state") != "retry_wait" || operation.S("errorCode") != "git_cleanup_failed" { + t.Fatal("cleanup failure was not deferred", operation) + } + f.call("POST", f.path("/operations/"+operation.S("id")+"/retry"), core.Object{"version": operation.N("version")}, "cleanup-retry", 202) + f.drain() + if f.scalar("SELECT count(*) FROM sandbox_instances s JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 AND s.terminated_at IS NULL", pid) != 0 || f.scalar("SELECT count(*) FROM workspaces WHERE project_id=$1 AND deleted_at IS NULL", pid) != 0 { + t.Fatal("cascade left a live resource") + } + if f.scalar("SELECT count(*) FROM project_storage WHERE project_id=$1 AND observed_state='deleted'", pid) != 1 { + t.Fatal("storage not confirmed deleted") + } +} diff --git a/integration/security_test.go b/integration/security_test.go new file mode 100644 index 0000000..1fce2b5 --- /dev/null +++ b/integration/security_test.go @@ -0,0 +1,379 @@ +package integration + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/simulator" +) + +func TestCredentialVerificationAndDisabledAccounts(t *testing.T) { + f := setup(t) + gateway, err := f.client.Credentials.Token("gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}) + must(t, err) + valid := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Issuer: "ora-simulator", Subject: "alice", Audience: jwt.ClaimStrings{"ora-cloud"}, IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute))}, Kind: "user", Source: "corp", Caller: "gateway-a"} + cases := []string{"expired", "wrong-audience", "wrong-issuer", "caller-mismatch", "forged", "none", "future", "too-long", "missing-expiry", "source-missing"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + claims := valid + key := any(f.client.Credentials.Private["user"]) + method := jwt.SigningMethod(jwt.SigningMethodEdDSA) + switch name { + case "expired": + claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)) + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute)) + case "wrong-audience": + claims.Audience = jwt.ClaimStrings{"ora-controller"} + case "wrong-issuer": + claims.Issuer = "untrusted" + case "caller-mismatch": + claims.Caller = "other-gateway" + case "forged": + _, private, e := ed25519.GenerateKey(rand.Reader) + must(t, e) + key = private + case "none": + method = jwt.SigningMethodNone + key = jwt.UnsafeAllowNoneSignatureType + case "future": + claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(time.Minute)) + case "too-long": + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour)) + case "missing-expiry": + claims.ExpiresAt = nil + case "source-missing": + claims.Source = "" + } + token := jwt.NewWithClaims(method, claims) + token.Header["kid"] = "user" + raw, e := token.SignedString(key) + must(t, e) + req, e := http.NewRequest("GET", f.cloud.URL+"/api/v1/me", nil) + must(t, e) + req.Header.Set("Authorization", "Bearer "+gateway) + req.Header.Set("X-Ora-User-Token", raw) + res, e := f.client.HTTP.Do(req) + must(t, e) + defer res.Body.Close() + if res.StatusCode != 401 { + t.Fatalf("accepted %s credential: %d", name, res.StatusCode) + } + }) + } + userCredential := f.user + userCredential.Caller = "gateway-a" + userToken, err := f.client.Credentials.Token("user", userCredential) + must(t, err) + for name, authorization := range map[string]string{"bare": gateway, "wrong-scheme": "Basic " + gateway, "extra-token": "Bearer " + gateway + " extra"} { + t.Run("authorization-"+name, func(t *testing.T) { + req, e := http.NewRequest("GET", f.cloud.URL+"/api/v1/me", nil) + must(t, e) + req.Header.Set("Authorization", authorization) + req.Header.Set("X-Ora-User-Token", userToken) + res, e := f.client.HTTP.Do(req) + must(t, e) + defer res.Body.Close() + if res.StatusCode != 401 { + t.Fatalf("accepted invalid Authorization form: %d", res.StatusCode) + } + }) + } + // A real signed controller credential cannot use the public gateway surface. + _, status, e := f.client.Call(context.Background(), "GET", "/api/v1/me", "controller", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "controller-a"}}, &f.user, "", nil) + must(t, e) + if status != 403 { + t.Fatal("service role confused", status) + } + f.user.Subject = "disabled" + user := f.call("GET", "/api/v1/me", nil, "", 200) + _, e = f.store.Pool.Exec("UPDATE users SET status='disabled' WHERE id=$1", user.S("id")) + must(t, e) + f.call("GET", "/api/v1/me", nil, "", 403) + f.user.Subject = "alice" + f.user.Source = "another-account-namespace" + other := f.call("GET", "/api/v1/me", nil, "", 200) + if other.S("id") == f.uid { + t.Fatal("source namespaces merged by subject/name") + } +} + +func TestConcurrentIdempotencyAndLastAdminProtection(t *testing.T) { + f := setup(t) + var wg sync.WaitGroup + results := make(chan core.Object, 16) + errs := make(chan string, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + o, status, e := f.client.Call(context.Background(), "POST", f.path("/projects"), "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, "same-key", core.Object{"name": "Concurrent", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main"}) + if e != nil || status != 202 { + errs <- fmt.Sprint(status, e, o) + return + } + results <- o + }() + } + wg.Wait() + close(results) + close(errs) + for e := range errs { + t.Error(e) + } + pid := "" + for o := range results { + if pid == "" { + pid = o.O("resource").S("id") + } + if pid != o.O("resource").S("id") { + t.Fatal("duplicate project") + } + } + if f.scalar("SELECT count(*) FROM projects") != 1 || f.scalar("SELECT count(*) FROM workspaces") != 1 || f.scalar("SELECT count(*) FROM operations") != 1 { + t.Fatal("non-atomic create") + } + f.drain() + f.user.Subject = "bob" + bob := f.call("GET", "/api/v1/me", nil, "", 200) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+bob.S("id")), core.Object{"role": "admin", "status": "active", "version": 0}, "", 200) + subjects := []string{"alice", "bob"} + ids := []string{f.uid, bob.S("id")} + statuses := make(chan int, 2) + for i := range subjects { + wg.Add(1) + go func(i int) { + defer wg.Done() + u := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: subjects[i]}, Source: "corp"} + _, status, _ := f.client.Call(context.Background(), "PUT", f.path("/members/"+ids[i]), "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &u, "", core.Object{"role": "member", "status": "active", "version": 1}) + statuses <- status + }(i) + } + wg.Wait() + close(statuses) + success, conflict := 0, 0 + for status := range statuses { + if status == 200 { + success++ + } + if status == 409 { + conflict++ + } + } + if success != 1 || conflict != 1 { + t.Fatalf("last admin race success=%d conflict=%d", success, conflict) + } + if f.scalar("SELECT count(*) FROM tenant_memberships WHERE tenant_id=$1 AND role='admin' AND status='active'", f.tid) != 1 { + t.Fatal("lost last administrator") + } +} + +func TestControllerTakeoverReconcilesAndFences(t *testing.T) { + f := setup(t) + created := f.create("create") + pid, wid := created.O("resource").S("id"), created.O("workspace").S("id") + f.substrate.SetFault("sandbox_ensure", "lose_response") + if e := f.controller.Drain(context.Background()); e == nil { + t.Fatal("expected response loss") + } + oldOperation := f.call("GET", f.path("/operations/"+created.O("operation").S("id")), nil, "", 200) + if oldOperation.S("state") != "retry_wait" || oldOperation.S("errorCode") != "substrate_timeout" { + t.Fatal("lost response was not deferred", oldOperation) + } + oldEpoch := f.controller.Epoch + // Test-only clock manipulation avoids sleeping for the production 30-second lease. + _, e := f.store.Pool.Exec("UPDATE controller_leases SET expires_at=clock_timestamp()-interval '1 second'") + must(t, e) + _, e = f.store.Pool.Exec("UPDATE operations SET retry_at=clock_timestamp()-interval '1 second' WHERE id=$1", oldOperation.S("id")) + must(t, e) + replacementClient := *f.client + replacementClient.Subject = "controller-b" + replacement := &simulator.Controller{Client: &replacementClient, SubstrateURL: f.controller.SubstrateURL} + must(t, replacement.Acquire(context.Background())) + if replacement.Epoch != oldEpoch+1 { + t.Fatal("takeover epoch not advanced") + } + f.internal("/internal/v1/operations/"+oldOperation.S("id")+"/advance", core.Object{"epoch": oldEpoch, "version": oldOperation.N("version")}, 409) + f.substrate.SetFault("sandbox_ensure", "") + must(t, replacement.Drain(context.Background())) + if f.ws(wid).S("observedState") != "ready" { + t.Fatal("takeover did not recover") + } + if f.scalar("SELECT count(*) FROM sandbox_instances WHERE workspace_id=$1", wid) != 1 { + t.Fatal("takeover recreated existing sandbox") + } + if f.scalar("SELECT count(*) FROM external_effects WHERE project_id=$1 AND kind='sandbox_ensure'", pid) != 1 { + t.Fatal("effect identity lost") + } + f.internal("/internal/v1/admissions", core.Object{"epoch": oldEpoch, "tenantId": f.tid, "workspaceId": wid, "action": "execute", "ticketId": uuid.NewString(), "kind": "task"}, 409) + // The old holder cannot renew or release the new holder's lease. + f.internal("/internal/v1/controller-lease/renew", core.Object{"epoch": oldEpoch}, 409) + f.internal("/internal/v1/controller-lease/release", core.Object{"epoch": oldEpoch}, 409) +} + +func TestTerminationUnknownRetryAndVersionedReplay(t *testing.T) { + f := setup(t) + created := f.create("create") + f.drain() + wid := created.O("workspace").S("id") + oldVersion := f.ws(wid).N("version") + stopped := f.call("POST", f.path("/workspaces/"+wid+"/stop"), core.Object{"version": oldVersion}, "stop", 202) + oid := stopped.O("operation").S("id") + f.substrate.SetFault("sandbox_terminate", "unconfirmed") + if e := f.controller.Drain(context.Background()); e == nil { + t.Fatal("termination must block") + } + if f.scalar("SELECT count(*) FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", wid) != 1 { + t.Fatal("unconfirmed sandbox forgotten") + } + f.call("POST", f.path("/workspaces/"+wid+"/start"), core.Object{"version": f.ws(wid).N("version")}, "premature-start", 409) + deferred := f.call("GET", f.path("/operations/"+oid), nil, "", 200) + if deferred.S("state") != "blocked" || deferred.S("errorCode") != "termination_unconfirmed" { + t.Fatal("unconfirmed termination was not blocked", deferred) + } + replay := f.call("POST", f.path("/workspaces/"+wid+"/stop"), core.Object{"version": oldVersion}, "stop", 202) + if replay.O("operation").S("id") != oid { + t.Fatal("stale-version replay not original operation") + } + f.call("POST", f.path("/operations/"+oid+"/retry"), core.Object{"version": deferred.N("version")}, "retry", 202) + f.substrate.SetFault("sandbox_terminate", "") + f.controller.Operation = nil + f.drain() + if f.ws(wid).S("observedState") != "stopped" { + t.Fatal("retry did not stop") + } +} + +func TestProjectCreationDeletionSerializationAndStrictInputs(t *testing.T) { + f := setup(t) + created := f.create("create") + f.drain() + pid := created.O("resource").S("id") + p := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + f.call("PATCH", f.path("/projects/"+pid), core.Object{"version": p.N("version"), "repositoryUrl": "https://example.invalid/other"}, "", 400) + f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "evil", "baseRef": "main", "relativePath": "../../etc"}, "unsafe-path", 400) + f.call("POST", f.path("/projects"), core.Object{"name": "secret", "repositoryUrl": "https://user:password@example.invalid/repo"}, "url-password", 400) + var wg sync.WaitGroup + var createStatus, deleteStatus int + wg.Add(2) + go func() { + defer wg.Done() + _, createStatus, _ = f.client.Call(context.Background(), "POST", f.path("/projects/"+pid+"/workspaces"), "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, "race-create", core.Object{"title": "Concurrent", "baseRef": "main"}) + }() + go func() { + defer wg.Done() + _, deleteStatus, _ = f.client.Call(context.Background(), "DELETE", f.path("/projects/"+pid), "gateway", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "gateway-a"}}, &f.user, "race-delete", core.Object{"version": p.N("version")}) + }() + wg.Wait() + if (createStatus != 202 || deleteStatus != 409) && (createStatus != 409 || deleteStatus != 202) { + t.Fatalf("create=%d delete=%d", createStatus, deleteStatus) + } + f.drain() + if deleteStatus == 202 && f.scalar("SELECT count(*) FROM workspaces WHERE project_id=$1 AND deleted_at IS NULL", pid) != 0 { + t.Fatal("workspace escaped cascade") + } +} + +func TestWrongWorkspaceNodeCannotRefuseAnotherStop(t *testing.T) { + f := setup(t) + p := f.create("create") + f.drain() + pid, main := p.O("resource").S("id"), p.O("workspace").S("id") + isolated := f.call("POST", f.path("/projects/"+pid+"/workspaces"), core.Object{"title": "Side", "baseRef": "main"}, "side", 202) + f.drain() + wid := isolated.O("resource").S("id") + n := f.node(wid) + var nv int64 + must(t, f.store.Pool.QueryRow("SELECT version FROM node_instances WHERE id=$1", n.Subject).Scan(&nv)) + out, status, e := f.client.Call(context.Background(), "POST", "/internal/v1/nodes/status", "node", n, nil, "", core.Object{"version": nv, "connectionState": "disconnected", "initialized": true}) + must(t, e) + if status != 200 { + t.Fatal(out) + } + stop := f.call("POST", f.path("/workspaces/"+main+"/stop"), core.Object{"version": f.ws(main).N("version")}, "stop-main", 202) + _, status, e = f.client.Call(context.Background(), "POST", "/internal/v1/nodes/idle", "node", n, nil, "", core.Object{"version": out.N("version"), "operationId": stop.O("operation").S("id"), "admissionEpoch": f.ws(wid).N("admissionEpoch"), "idle": false}) + must(t, e) + if status != 409 { + t.Fatal("wrong workspace affected operation", status) + } + op := f.call("GET", f.path("/operations/"+stop.O("operation").S("id")), nil, "", 200) + if op.S("state") == "failed" { + t.Fatal("unrelated node canceled stop") + } +} + +func TestPostgresAggregateConstraints(t *testing.T) { + f := setup(t) + p := f.create("create") + pid, wid := p.O("resource").S("id"), p.O("workspace").S("id") + other, e := f.store.Bootstrap(context.Background(), "Other", "corp", "other", "Other") + must(t, e) + checks := []struct { + name, q string + args []any + }{ + {"project must have main", "INSERT INTO projects(id,tenant_id,owner_user_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,'missing main','https://x','main','active')", []any{uuid.NewString(), f.tid, f.uid}}, + {"main cannot vanish", "UPDATE workspaces SET deleted_at=now() WHERE id=$1", []any{wid}}, + {"main cannot change aggregate", "UPDATE workspaces SET project_id=$1 WHERE id=$2", []any{uuid.NewString(), wid}}, + {"cross-tenant owner", "INSERT INTO workspaces(id,tenant_id,owner_user_id,project_id,kind,desired_state,observed_state) VALUES($1,$2,$3,$4,'isolated','running','provisioning')", []any{uuid.NewString(), other.S("tenantId"), other.S("userId"), pid}}, + {"task requires isolated", "INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,'wrong main task')", []any{uuid.NewString(), wid}}, + {"last admin user cannot disable", "UPDATE users SET status='disabled' WHERE id=$1", []any{f.uid}}, + {"last admin member cannot disable", "UPDATE tenant_memberships SET status='disabled' WHERE tenant_id=$1 AND user_id=$2", []any{f.tid, f.uid}}, + {"cross-tenant operation", "INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,'start','failed','sandbox','{}','x','x')", []any{uuid.NewString(), other.S("tenantId"), other.S("userId"), pid, wid}}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if _, e := f.store.Pool.Exec(check.q, check.args...); e == nil { + t.Fatal("constraint accepted invalid aggregate") + } + }) + } + f.drain() + if _, e = f.store.Pool.Exec("INSERT INTO sandbox_instances(id,workspace_id,generation,observed_state) VALUES($1,$2,2,'allocating')", uuid.NewString(), wid); e == nil { + t.Fatal("multiple live sandboxes accepted") + } + // Direct SQL is used here only to verify constraints, never by simulated Controller. + f.user.Subject = "bob" + bob := f.call("GET", "/api/v1/me", nil, "", 200) + f.user.Subject = "alice" + f.call("PUT", f.path("/members/"+bob.S("id")), core.Object{"role": "member", "status": "active", "version": 0}, "", 200) + ref, e := f.store.ConfigureCredential(context.Background(), f.tid, bob.S("id"), "secret://bob/git") + must(t, e) + f.call("POST", f.path("/projects"), core.Object{"name": "wrong credential", "repositoryUrl": "https://example.invalid/repo.git", "credentialRefId": ref.S("id")}, "credential-owner", 404) +} + +func TestListPaginationAndErrorShape(t *testing.T) { + f := setup(t) + for i := 0; i < 3; i++ { + f.create(fmt.Sprintf("p%d", i)) + } + first := f.call("GET", f.path("/projects?limit=2"), nil, "", 200) + items := first["items"].([]any) + if len(items) != 2 || first.S("nextCursor") == "" { + t.Fatal(first) + } + next := f.call("GET", f.path("/projects?limit=2&after="+first.S("nextCursor")), nil, "", 200) + if len(next["items"].([]any)) != 1 || next.S("nextCursor") != "" { + t.Fatal(next) + } + if core.Object(items[0].(map[string]any)).S("id") >= core.Object(items[1].(map[string]any)).S("id") { + t.Fatal("unstable ordering") + } + err := f.call("GET", f.path("/projects?limit=200"), nil, "", 400) + b, _ := json.Marshal(err) + if err.S("code") == "" || err.S("requestId") == "" || err["params"] == nil || strings.Contains(string(b), "SQL") { + t.Fatal("invalid error envelope", err) + } +} diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..c1d3e8c --- /dev/null +++ b/internal/README.md @@ -0,0 +1,32 @@ +# internal: Authoritative Cloud Subsystems + +`internal` hosts the private implementation packages for Ora Cloud. Per the repository architectural boundary rules documented in `AGENTS.md`, all core state, policy, translation, and infrastructure adapters remain private under `internal/`. + +## Module map + +- [core](core/README.md) is the authoritative domain core, owning business state machines, transaction boundaries, database advisory locks, and cryptographic authentication. + - [migrations](core/migrations/README.md) contains ordered, forward-only PostgreSQL schema migration scripts and checksum verification. +- [api](api/README.md) is the HTTP presentation layer. + - [router](api/router/README.md) binds HTTP routes, verifies two-tier JWT credentials, parses JSON request bodies, and projects domain errors into stable contracts. +- [contract](contract/README.md) defines OpenAPI 3.0 schema models, DTO structures, and contract coverage tests. +- [repository](repository/README.md) manages PostgreSQL database connection pools and startup health checks via GORM. +- [config](config/README.md) loads and validates application configuration files and environment overrides. +- [logger](logger/README.md) provides structured, non-blocking JSON logging via Zap and Lumberjack. +- [simulator](simulator/README.md) implements in-process doubles for the Substrate execution engine, Controller, and Workspace Node. + +## Layering and architectural rules + +1. **Unidirectional dependencies**: + - `cmd/*` $\rightarrow$ `internal/api/router`, `internal/core`, `internal/config`, `internal/logger`, `internal/repository`. + - `internal/api/router` $\rightarrow$ `internal/core`, `internal/contract`. + - `internal/core` $\rightarrow$ standard library, `gorm.io/gorm`, `internal/core/migrations`. + - `internal/repository` $\rightarrow$ `internal/config`, `gorm.io/gorm`. + - Lower layers (`core`, `repository`) never import upper presentation layers (`api`, `router`). +2. **PostgreSQL is authoritative**: + - All shared state is persisted in PostgreSQL. In-memory caching of authoritative domain state across requests is strictly prohibited. +3. **Transaction boundary**: + - Database transactions and advisory locks are strictly localized to PostgreSQL operations inside `core.Store.transact`. Transactions must **never** be held across external HTTP requests, Git CLI commands, or filesystem I/O. +4. **Error handling**: + - Public-facing errors use the stable `Fault` structure (`Code`, `Params`, `Status`). Internal SQL, stack traces, and database errors are logged internally and never returned to clients. + +See [AGENTS.md](../AGENTS.md), [Core contract](../docs/core-contract.md), and [Authentication](../docs/authentication.md). diff --git a/internal/api/README.md b/internal/api/README.md new file mode 100644 index 0000000..b8764b2 --- /dev/null +++ b/internal/api/README.md @@ -0,0 +1,16 @@ +# internal/api: HTTP Presentation Layer + +`internal/api` contains the HTTP translation and routing components of Ora Cloud. It translates inbound HTTP requests into domain requests for `internal/core`, enforces transport security and payload size bounds, and formats responses and errors according to the OpenAPI contract. + +## Module map + +- [router](router/README.md) configures the Gin HTTP engine, registers routes, verifies caller credentials, parses request bodies with strict limits, and maps domain errors to public fault contracts. + +## Responsibilities and boundaries + +- **Transport translation only**: This layer is purely an adapter between HTTP and the core domain. It contains no business logic, state machines, or SQL queries. +- **Strict input validation**: Decodes JSON bodies with 64KB size limits and rejects requests with unknown or malformed fields before invoking domain methods. +- **Contract conformity**: All routes, query parameters, request bodies, and response codes adhere strictly to the specification defined in `internal/contract` and `api/openapi.json`. +- **Database isolation**: Handlers in this layer never touch database handles or GORM instances directly; all database interaction is mediated by `core.Store`. + +See [HTTP router](router/README.md), [Contract package](../contract/README.md), and [API specification](../../api/openapi.json). diff --git a/internal/api/handler/health_handler.go b/internal/api/handler/health_handler.go deleted file mode 100644 index bcf124d..0000000 --- a/internal/api/handler/health_handler.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package handler provides HTTP request handlers for the application. -package handler - -import ( - "time" - - "github.com/gin-gonic/gin" - - "github.com/wanglongan587/cloud/pkg/response" -) - -// HealthHandler handles health check endpoint -type HealthHandler struct{} - -// NewHealthHandler creates a new HealthHandler -func NewHealthHandler() *HealthHandler { - return &HealthHandler{} -} - -// Check returns service health status -func (h *HealthHandler) Check(c *gin.Context) { - data := gin.H{ - "status": "UP", - "timestamp": time.Now().Format(time.RFC3339), - "service": "cloud-backend", - } - response.Success(c, data) -} diff --git a/internal/api/handler/health_handler_test.go b/internal/api/handler/health_handler_test.go deleted file mode 100644 index 84742c8..0000000 --- a/internal/api/handler/health_handler_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/wanglongan587/cloud/pkg/response" -) - -func TestHealthCheck(t *testing.T) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - - h := NewHealthHandler() - h.Check(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) - } - - var res response.Response - if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if res.Code != response.CodeSuccess { - t.Fatalf("expected response code %d, got %d", response.CodeSuccess, res.Code) - } -} diff --git a/internal/api/handler/user_handler.go b/internal/api/handler/user_handler.go deleted file mode 100644 index 5821643..0000000 --- a/internal/api/handler/user_handler.go +++ /dev/null @@ -1,75 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/wanglongan587/cloud/internal/model" - "github.com/wanglongan587/cloud/internal/service" - "github.com/wanglongan587/cloud/pkg/response" -) - -// UserHandler handles user-related HTTP requests -type UserHandler struct { - userService service.UserService -} - -// NewUserHandler creates a new UserHandler instance -func NewUserHandler(userService service.UserService) *UserHandler { - return &UserHandler{userService: userService} -} - -// CreateUser handles POST /api/v1/users -func (h *UserHandler) CreateUser(c *gin.Context) { - var req model.CreateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - user, err := h.userService.CreateUser(c.Request.Context(), &req) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - - response.Success(c, user) -} - -// GetUser handles GET /api/v1/users/:id -func (h *UserHandler) GetUser(c *gin.Context) { - idParam := c.Param("id") - id, err := strconv.ParseUint(idParam, 10, 64) - if err != nil { - response.BadRequest(c, "invalid user id") - return - } - - user, err := h.userService.GetUser(c.Request.Context(), uint(id)) - if err != nil { - response.NotFound(c, "user not found") - return - } - - response.Success(c, user) -} - -// ListUsers handles GET /api/v1/users?page=1&page_size=10 -func (h *UserHandler) ListUsers(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) - - users, total, err := h.userService.ListUsers(c.Request.Context(), page, pageSize) - if err != nil { - response.ServerError(c, err.Error()) - return - } - - response.Success(c, gin.H{ - "items": users, - "total": total, - "page": page, - "page_size": pageSize, - }) -} diff --git a/internal/api/middleware/cors.go b/internal/api/middleware/cors.go deleted file mode 100644 index 597bd71..0000000 --- a/internal/api/middleware/cors.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package middleware provides Gin HTTP middlewares. -package middleware - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// CORS handles Cross-Origin Resource Sharing -func CORS() gin.HandlerFunc { - return func(c *gin.Context) { - method := c.Request.Method - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Headers", "Content-Type, AccessToken, X-CSRF-Token, Authorization, Token, X-Token") - c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE") - c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type") - c.Header("Access-Control-Allow-Credentials", "true") - - if method == "OPTIONS" { - c.AbortWithStatus(http.StatusNoContent) - return - } - - c.Next() - } -} diff --git a/internal/api/middleware/logger.go b/internal/api/middleware/logger.go deleted file mode 100644 index 1065785..0000000 --- a/internal/api/middleware/logger.go +++ /dev/null @@ -1,47 +0,0 @@ -package middleware - -import ( - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/wanglongan587/cloud/pkg/logger" -) - -// ZapLogger is a Gin middleware that logs HTTP requests via Uber Zap -func ZapLogger() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - path := c.Request.URL.Path - query := c.Request.URL.RawQuery - - c.Next() - - cost := time.Since(start) - status := c.Writer.Status() - - fields := []zap.Field{ - zap.Int("status", status), - zap.String("method", c.Request.Method), - zap.String("path", path), - zap.String("query", query), - zap.String("ip", c.ClientIP()), - zap.String("user-agent", c.Request.UserAgent()), - zap.Duration("cost", cost), - } - - if len(c.Errors) > 0 { - fields = append(fields, zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String())) - } - - switch { - case status >= 500: - logger.Log.Error("Server Error", fields...) - case status >= 400: - logger.Log.Warn("Client Error", fields...) - default: - logger.Log.Info("HTTP Request", fields...) - } - } -} diff --git a/internal/api/middleware/recovery.go b/internal/api/middleware/recovery.go deleted file mode 100644 index 65b17d7..0000000 --- a/internal/api/middleware/recovery.go +++ /dev/null @@ -1,55 +0,0 @@ -package middleware - -import ( - "net" - "os" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/wanglongan587/cloud/pkg/logger" - "github.com/wanglongan587/cloud/pkg/response" -) - -// ZapRecovery is a Gin middleware that recovers from panics and logs with Zap -func ZapRecovery() gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if err := recover(); err != nil { - // Check for broken pipe - var brokenPipe bool - if ne, ok := err.(*net.OpError); ok { - if se, ok := ne.Err.(*os.SyscallError); ok { - if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || - strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") { - brokenPipe = true - } - } - } - - if brokenPipe { - logger.Log.Error("Broken pipe error", - zap.Any("error", err), - zap.String("path", c.Request.URL.Path), - ) - if e, ok := err.(error); ok { - _ = c.Error(e) - } - c.Abort() - return - } - - logger.Log.Error("Panic recovered", - zap.Any("error", err), - zap.String("path", c.Request.URL.Path), - zap.Stack("stack"), - ) - - response.ServerError(c, "Internal server error") - c.Abort() - } - }() - c.Next() - } -} diff --git a/internal/api/router/README.md b/internal/api/router/README.md new file mode 100644 index 0000000..95e9d50 --- /dev/null +++ b/internal/api/router/README.md @@ -0,0 +1,35 @@ +# internal/api/router: HTTP Router & Transport Adapter + +`internal/api/router` establishes the HTTP presentation boundary for Ora Cloud. Built on top of Gin, it binds HTTP routes, verifies two-tier JWT authentication, enforces strict request body parsing limits, normalizes errors, and dispatches requests to `internal/core`. + +## Responsibilities + +### Route allowlist and dispatch +- `Routes()` declares the explicit allowlist of supported endpoints: + - **Public API (`/api/v1/...`)**: 19 endpoints for users, tenants, memberships, projects, workspaces, operations, and status queries. + - **Internal Control API (`/internal/v1/...`)**: 15 endpoints for controller leasing, operation claiming/advancing, node registration, and ticket admissions. + - **Health check (`/healthz`)**: Verifies database reachability via `store.Pool.PingContext`. +- Any unregistered endpoint is caught by `r.NoRoute` and returns `404 not_found`. + +### Two-tier authentication +- **Service credential**: Read from the standard `Authorization: Bearer ` header. Must be a valid JWT signed by an authorized key with `kind="service"`. +- **Gateway authorization**: For public endpoints, the service token must possess the `role="gateway"`. +- **User credential**: Read from `X-Ora-User-Token: Bearer `. Must be signed with `kind="user"`. +- **Caller-subject binding**: Enforces that `user.Caller == service.Subject` to prevent credential impersonation across gateways. + +### Strict request validation and decoding +- **Payload size bound**: Enforces a strict 64 KiB ceiling on incoming request bodies via `http.MaxBytesReader`. +- **Strict JSON hygiene**: Uses `json.Decoder` with `UseNumber()`. Trailing bytes or extraneous JSON values are rejected with `400 invalid_json`. +- **Disallowed fields**: Only fields explicitly listed in `Route.Fields` are permitted in the JSON body. Unknown properties immediately fail with `400 unknown_field`. +- **Type validation**: Field types are rigorously checked (`validField`), ensuring timestamps, UUIDs, integers, and boolean properties conform to expected schemas before reaching the domain core. + +### Fault projection and correlation +- **Request correlation**: Generates a unique UUID `X-Request-Id` for every incoming HTTP request, attaching it to the request context, response header, and structured log events. +- **Error normalization**: Traps panics and domain errors via `core.ErrorCode(err)`. Translates `*core.Fault` into structured JSON responses (`code`, `params`, `requestId`) with appropriate HTTP status codes. Internal database or system errors are masked as `500 internal_error` without disclosing internal infrastructure details. + +## Boundaries and invariants + +- **No business state**: The router owns no business logic, domain state transitions, or database connections. It delegates entirely to `core.Store.Public` and `core.Store.Control`. +- **Contract synchronization**: Route paths, allowed fields, and HTTP methods must stay synchronized with `internal/contract` and `api/openapi.json`. + +See [api overview](../README.md), [Core domain](../../core/README.md), [OpenAPI contract](../../contract/README.md), and [Authentication](../../../docs/authentication.md). diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 494eda3..85ff536 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -1,47 +1,233 @@ -// Package router initializes and registers application routes. +// Package router binds typed routes to the cloud core and verifies two independent credentials. package router import ( + "encoding/json" + "io" + "net/http" + "runtime/debug" + "strconv" + "strings" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" - "github.com/wanglongan587/cloud/internal/api/handler" - "github.com/wanglongan587/cloud/internal/api/middleware" - "github.com/wanglongan587/cloud/internal/config" - "github.com/wanglongan587/cloud/internal/repository" - "github.com/wanglongan587/cloud/internal/service" + "github.com/wanglongan587/cloud/internal/core" ) -// InitRouter initializes the Gin router and registers all routes -func InitRouter(cfg *config.Config) *gin.Engine { - if cfg.Server.Mode != "" { - gin.SetMode(cfg.Server.Mode) +// Route describes the implemented contract, also used by the OpenAPI coverage test. +type Route struct { + Method, Path, Action string + Fields []string +} + +// Routes is an explicit allowlist. Unknown JSON properties cannot set server-owned bindings. +func Routes() []Route { + return []Route{ + {"GET", "/api/v1/me", "", nil}, + {"GET", "/api/v1/me/tenants", "", nil}, + {"GET", "/api/v1/tenants/:tid/members", "", nil}, + {"PUT", "/api/v1/tenants/:tid/members/:uid", "", []string{"role", "status", "version"}}, + {"GET", "/api/v1/tenants/:tid/projects", "", nil}, + {"POST", "/api/v1/tenants/:tid/projects", "", []string{"name", "repositoryUrl", "defaultBranch", "credentialRefId"}}, + {"GET", "/api/v1/tenants/:tid/projects/:pid", "", nil}, + {"PATCH", "/api/v1/tenants/:tid/projects/:pid", "", []string{"name", "version"}}, + {"DELETE", "/api/v1/tenants/:tid/projects/:pid", "", []string{"version"}}, + {"GET", "/api/v1/tenants/:tid/projects/:pid/workspaces", "", nil}, + {"POST", "/api/v1/tenants/:tid/projects/:pid/workspaces", "", []string{"title", "baseRef"}}, + {"GET", "/api/v1/tenants/:tid/workspaces/:wid", "", nil}, + {"POST", "/api/v1/tenants/:tid/workspaces/:wid/start", "", []string{"version"}}, + {"POST", "/api/v1/tenants/:tid/workspaces/:wid/stop", "", []string{"version"}}, + {"DELETE", "/api/v1/tenants/:tid/workspaces/:wid", "", []string{"version"}}, + {"GET", "/api/v1/tenants/:tid/operations/:oid", "", nil}, + {"POST", "/api/v1/tenants/:tid/operations/:oid/retry", "", []string{"version"}}, + {"GET", "/api/v1/tenants/:tid/resource-status", "", nil}, + {"POST", "/api/v1/tenants/:tid/workspaces/:wid/administrative-stop", "", []string{"version"}}, + {"POST", "/internal/v1/access", "access", []string{"tenantId", "workspaceId", "action", "epoch"}}, + {"POST", "/internal/v1/admissions", "admit", []string{"tenantId", "workspaceId", "action", "ticketId", "kind", "epoch"}}, + {"POST", "/internal/v1/controller-lease/acquire", "lease_acquire", []string{}}, + {"POST", "/internal/v1/controller-lease/renew", "lease_renew", []string{"epoch"}}, + {"POST", "/internal/v1/controller-lease/release", "lease_release", []string{"epoch"}}, + {"POST", "/internal/v1/operations/claim", "claim", []string{"epoch"}}, + {"POST", "/internal/v1/operations/:oid/snapshot", "snapshot", []string{"epoch", "version"}}, + {"POST", "/internal/v1/operations/:oid/effects", "plan", []string{"epoch", "version", "kind", "workspaceId"}}, + {"POST", "/internal/v1/operations/:oid/effects/:eid/result", "effect_result", []string{"epoch", "version", "state", "externalId", "result"}}, + {"POST", "/internal/v1/operations/:oid/advance", "advance", []string{"epoch", "version"}}, + {"POST", "/internal/v1/operations/:oid/defer", "defer", []string{"epoch", "version", "state", "errorCode", "retrySeconds"}}, + {"POST", "/internal/v1/nodes/register", "node_register", []string{"protocolVersion"}}, + {"POST", "/internal/v1/nodes/status", "node_status", []string{"version", "connectionState", "initialized"}}, + {"POST", "/internal/v1/nodes/idle", "node_idle", []string{"version", "admissionEpoch", "idle", "operationId"}}, + {"POST", "/internal/v1/nodes/tickets/:ticket/finish", "node_finish", []string{"version"}}, } +} +// New injects the store, trust configuration, and logger. No public user CRUD is registered. +func New(store *core.Store, auth *core.Authenticator, log *zap.Logger) *gin.Engine { r := gin.New() + r.Use(func(c *gin.Context) { + id := uuid.NewString() + c.Set("requestId", id) + c.Header("X-Request-Id", id) + defer func() { + if recovered := recover(); recovered != nil { + log.Error("request panic", zap.String("requestId", id), zap.Any("panic", recovered), zap.ByteString("stack", debug.Stack())) + failure(c, &core.Fault{Code: "internal_error", Status: 500, Params: core.Object{}}) + } + }() + c.Next() + }) + r.GET("/healthz", func(c *gin.Context) { + if e := store.Pool.PingContext(c.Request.Context()); e != nil { + failure(c, &core.Fault{Code: "database_unavailable", Status: 503, Params: core.Object{}}) + return + } + c.JSON(200, gin.H{"status": "ok"}) + }) + for _, route := range Routes() { + r.Handle(route.Method, route.Path, func(c *gin.Context) { + raw, ok := bearerToken(c.GetHeader("Authorization")) + if !ok { + failure(c, &core.Fault{Code: "invalid_service_credential", Status: 401, Params: core.Object{}}) + return + } + service, e := auth.Verify(raw, "service") + if e != nil { + failure(c, &core.Fault{Code: "invalid_service_credential", Status: 401, Params: core.Object{}}) + return + } + public := route.Action == "" + var user *core.Claims + if public || route.Action == "access" || route.Action == "admit" { + if public && service.Role != "gateway" { + failure(c, &core.Fault{Code: "service_forbidden", Status: 403, Params: core.Object{}}) + return + } + user, e = auth.Verify(c.GetHeader("X-Ora-User-Token"), "user") + if e != nil || user.Caller != service.Subject { + failure(c, &core.Fault{Code: "invalid_user_credential", Status: 401, Params: core.Object{}}) + return + } + } + body := core.Object{} + if c.Request.Method != "GET" { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + decoder := json.NewDecoder(c.Request.Body) + decoder.UseNumber() + if e = decoder.Decode(&body); e != nil || body == nil { + failure(c, &core.Fault{Code: "invalid_json", Status: 400, Params: core.Object{}}) + return + } + var extra any + if decoder.Decode(&extra) != io.EOF { + failure(c, &core.Fault{Code: "invalid_json", Status: 400, Params: core.Object{}}) + return + } + allowed := map[string]bool{} + for _, f := range route.Fields { + allowed[f] = true + } + for k := range body { + if !allowed[k] { + failure(c, &core.Fault{Code: "unknown_field", Status: 400, Params: core.Object{"field": k}}) + return + } + if !validField(k, body[k]) { + failure(c, &core.Fault{Code: "invalid_field_type", Status: 400, Params: core.Object{"field": k}}) + return + } + } + for _, k := range []string{"idle", "initialized"} { + for _, f := range route.Fields { + if f == k { + if _, ok := body[k]; !ok { + failure(c, &core.Fault{Code: "missing_field", Status: 400, Params: core.Object{"field": k}}) + return + } + } + } + } + } + var out core.Object + status := 200 + if public { + limit := 0 + if v := c.Query("limit"); v != "" { + limit, e = strconv.Atoi(v) + if e != nil || limit < 1 || limit > 100 { + failure(c, &core.Fault{Code: "invalid_pagination", Status: 400, Params: core.Object{}}) + return + } + } + out, status, e = store.Public(c.Request.Context(), &core.PublicRequest{Method: c.Request.Method, Path: c.Request.URL.Path, TenantID: c.Param("tid"), ProjectID: c.Param("pid"), WorkspaceID: c.Param("wid"), OperationID: c.Param("oid"), UserID: c.Param("uid"), Key: c.GetHeader("Idempotency-Key"), Limit: limit, After: c.Query("after"), Body: body, Identity: user}) + } else { + out, e = store.Control(c.Request.Context(), &core.ControlRequest{Action: route.Action, OperationID: c.Param("oid"), EffectID: c.Param("eid"), TicketID: c.Param("ticket"), Body: body, Service: service, Identity: user}) + } + if e != nil { + f := core.ErrorCode(e) + if f.Status == 500 { + log.Error("request failed", zap.String("requestId", c.GetString("requestId")), zap.Error(e)) + } + failure(c, f) + return + } + c.JSON(status, out) + }) + } + r.NoRoute(func(c *gin.Context) { failure(c, &core.Fault{Code: "not_found", Status: 404, Params: core.Object{}}) }) + return r +} - // Global Middlewares - r.Use(middleware.CORS()) - r.Use(middleware.ZapLogger()) - r.Use(middleware.ZapRecovery()) - - // Dependency Injection / Layer Assembly - userRepo := repository.NewUserRepository(repository.DB) - userService := service.NewUserService(userRepo) - userHandler := handler.NewUserHandler(userService) - healthHandler := handler.NewHealthHandler() - - // API Route Group - v1 := r.Group("/api/v1") - { - // Health Check - v1.GET("/health", healthHandler.Check) - - // User Routes - users := v1.Group("/users") - users.POST("", userHandler.CreateUser) - users.GET("", userHandler.ListUsers) - users.GET("/:id", userHandler.GetUser) +func bearerToken(header string) (string, bool) { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" { + return "", false } + return parts[1], true +} - return r +func validField(name string, value any) bool { + switch name { + case "version", "epoch", "admissionEpoch", "protocolVersion", "retrySeconds": + n, ok := value.(json.Number) + if !ok { + return false + } + _, e := n.Int64() + return e == nil + case "idle", "initialized": + _, ok := value.(bool) + return ok + case "result": + o, ok := value.(map[string]any) + if !ok { + return false + } + for k, v := range o { + switch k { + case "jobTerminated", "removed", "terminated": + if _, ok := v.(bool); !ok { + return false + } + case "layoutVersion": + if _, ok := v.(json.Number); !ok { + return false + } + case "commitId", "sandboxInstanceId", "nodeId": + if _, ok := v.(string); !ok { + return false + } + default: + return false + } + } + return true + default: + _, ok := value.(string) + return ok + } +} + +func failure(c *gin.Context, e *core.Fault) { + c.AbortWithStatusJSON(e.Status, gin.H{"code": e.Code, "params": e.Params, "requestId": c.GetString("requestId")}) } diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go new file mode 100644 index 0000000..20ddc6c --- /dev/null +++ b/internal/api/router/router_test.go @@ -0,0 +1,34 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestUnexpectedPanicIncludesValueAndStackInLogs(t *testing.T) { + gin.SetMode(gin.TestMode) + logCore, recorded := observer.New(zap.ErrorLevel) + engine := New(nil, nil, zap.New(logCore)) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + request.Header.Set("Authorization", "Bearer triggers-nil-authenticator") + + engine.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", response.Code) + } + entries := recorded.FilterMessage("request panic").All() + if len(entries) != 1 { + t.Fatalf("expected one panic log, got %d", len(entries)) + } + fields := entries[0].ContextMap() + if fields["panic"] == nil || fields["stack"] == "" { + t.Fatalf("panic log omitted value or stack: %v", fields) + } +} diff --git a/internal/config/README.md b/internal/config/README.md new file mode 100644 index 0000000..0478a41 --- /dev/null +++ b/internal/config/README.md @@ -0,0 +1,23 @@ +# internal/config: Configuration Loader & Validation + +`internal/config` manages configuration parsing, schema validation, and environment variable overrides for Ora Cloud. + +## Responsibilities + +- **Structured configuration**: Defines strongly-typed Go structs mapping the entire system configuration: + - `ServerConfig`: Port, Gin mode, read/write timeouts. + - `LoggerConfig`: Log level, file paths, rotation thresholds (max size, age, backups, gzip compression). + - `DatabaseConfig`: Driver (must be `postgres`), DSN, and connection pool limits (`max_open_conns`, `max_idle_conns`, `conn_max_lifetime`). + - `AuthConfig`: Expected token audience and list of `TrustedKey` verification parameters. +- **Hierarchical loading via Viper**: + - Searches for `config.yaml` in `./configs`, `../configs`, and `.`. + - Supports explicit file path overriding via `-config `. + - Maps environment variables with the `CLOUD_` prefix, replacing dots with underscores (e.g., `CLOUD_DATABASE_DSN` overrides `database.dsn`). +- **Startup sanity validation**: Rejects invalid configurations with explicit errors, requiring positive durations for `read_timeout`, `write_timeout`, and `conn_max_lifetime`. + +## Boundaries and invariants + +- **No secret storage**: Configuration files store only public verification keys and infrastructure references. Plaintext deployment secrets and private keys must never appear in configuration files. +- **Immutable runtime**: Configurations are loaded once at command startup and passed as ready-to-use values. There is no global mutable configuration singleton. + +See [config.yaml](../../configs/config.yaml), [cmd/server](../../cmd/server/README.md), and [Authentication](../../docs/authentication.md). diff --git a/internal/config/config.go b/internal/config/config.go index 2ed92d6..0912e4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,39 +2,48 @@ package config import ( + "fmt" "strings" + "time" "github.com/spf13/viper" - "github.com/wanglongan587/cloud/pkg/logger" + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/logger" ) -// Config holds all configuration of the application +// Config holds all configuration of the application. type Config struct { Server ServerConfig `mapstructure:"server"` Logger logger.Config `mapstructure:"logger"` Database DatabaseConfig `mapstructure:"database"` + Auth AuthConfig `mapstructure:"auth"` } -// ServerConfig holds HTTP server configuration +// AuthConfig contains only internal verification keys, never an external login SDK. +type AuthConfig struct { + Audience string `mapstructure:"audience"` + Keys []core.TrustedKey `mapstructure:"keys"` +} + +// ServerConfig holds HTTP server configuration. type ServerConfig struct { - Port int `mapstructure:"port"` - Mode string `mapstructure:"mode"` - ReadTimeout int `mapstructure:"read_timeout"` - WriteTimeout int `mapstructure:"write_timeout"` + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` + ReadTimeout time.Duration `mapstructure:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout"` } -// DatabaseConfig holds database connection parameters +// DatabaseConfig holds database connection parameters. type DatabaseConfig struct { - Driver string `mapstructure:"driver"` - DSN string `mapstructure:"dsn"` - MaxIdleConns int `mapstructure:"max_idle_conns"` - MaxOpenConns int `mapstructure:"max_open_conns"` - ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` - AutoMigrate bool `mapstructure:"auto_migrate"` + Driver string `mapstructure:"driver"` + DSN string `mapstructure:"dsn"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns"` + ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` } -// Load reads configuration from file and environment variables +// Load reads configuration from file and environment variables. func Load(configPath string) (*Config, error) { v := viper.New() @@ -61,6 +70,9 @@ func Load(configPath string) (*Config, error) { if err := v.Unmarshal(&cfg); err != nil { return nil, err } + if cfg.Server.ReadTimeout <= 0 || cfg.Server.WriteTimeout <= 0 || cfg.Database.ConnMaxLifetime <= 0 { + return nil, fmt.Errorf("server timeouts and database.conn_max_lifetime must be positive durations") + } return &cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..003c342 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,24 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadParsesDurations(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + contents := []byte("server:\n read_timeout: 10s\n write_timeout: 15s\ndatabase:\n conn_max_lifetime: 1h\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Server.ReadTimeout != 10*time.Second || cfg.Server.WriteTimeout != 15*time.Second || cfg.Database.ConnMaxLifetime != time.Hour { + t.Fatalf("durations were not parsed: %+v %+v", cfg.Server, cfg.Database) + } +} diff --git a/internal/contract/README.md b/internal/contract/README.md new file mode 100644 index 0000000..1f357d8 --- /dev/null +++ b/internal/contract/README.md @@ -0,0 +1,24 @@ +# internal/contract: API Contract & OpenAPI Specification + +`internal/contract` programmatically defines the authoritative OpenAPI 3.0 data models and schemas for Ora Cloud. It serves as the single source of truth for all API requests, responses, and fault definitions. + +## Responsibilities + +- **Programmatic OpenAPI generation**: `contract.Document()` builds the complete OpenAPI 3.0 specification tree, defining metadata, security schemes (HTTP Bearer JWT), parameters, request bodies, status codes, and response schemas. +- **Component schema modeling**: Defines strict JSON schemas for domain entities: + - Core resources: `Tenant`, `TenantMember`, `User`, `Project`, `Workspace`, `Task`, `Operation`, `Effect`, `WorkspaceNode`, `Ticket`. + - Error schema: Standardized `Fault` schema with error code, parameter mapping, and request ID. + - Parameter typing: Strong validation formats including `uuid`, `date-time`, `int64`, and string enumerations. +- **Contract verification tests**: + - `TestRoutesCovered`: Verifies that every route defined in `router.Routes()` is explicitly represented in the generated OpenAPI paths. + - `TestDocumentValid`: Validates the structural correctness of the generated OpenAPI JSON against specification rules. + +## Invariants and workflow + +- **No manual JSON edits**: `api/openapi.json` is generated directly from this package via `cmd/openapi`. Developers modify Go definitions here, run `task openapi`, and commit both the code and the resulting JSON artifact. +- **Three-way alignment**: Every API route change requires simultaneous updates to: + 1. `internal/api/router` (`router.Routes()`). + 2. `internal/contract` (`contract.Document()`). + 3. `api/openapi.json` (regenerated via `task openapi`). + +See [OpenAPI JSON artifact](../../api/openapi.json), [HTTP router](../api/router/README.md), and [cmd/openapi](../../cmd/openapi/README.md). diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go new file mode 100644 index 0000000..75ac137 --- /dev/null +++ b/internal/contract/openapi.go @@ -0,0 +1,359 @@ +// Package contract builds the explicit OpenAPI contract for the implemented route allowlist. +package contract + +import ( + "strings" + + "github.com/wanglongan587/cloud/internal/api/router" +) + +type obj = map[string]any + +func asObject(v any) obj { + o, ok := v.(map[string]any) + if !ok { + panic("invalid static OpenAPI object") + } + return o +} +func properties(s obj, name string) obj { return asObject(asObject(s[name])["properties"]) } + +func ref(name string) obj { return obj{"$ref": "#/components/schemas/" + name} } +func str() obj { return obj{"type": "string"} } +func number() obj { return obj{"type": "integer", "format": "int64"} } +func boolean() obj { return obj{"type": "boolean"} } +func enumeration(values ...string) obj { return obj{"type": "string", "enum": values} } +func array(item obj) obj { return obj{"type": "array", "items": item} } +func optional(s obj) obj { s["nullable"] = true; return s } +func object(properties obj, required ...string) obj { + return obj{"type": "object", "properties": properties, "required": required, "additionalProperties": false} +} +func uuid() obj { return obj{"type": "string", "format": "uuid"} } +func timestamp() obj { return obj{"type": "string", "format": "date-time"} } +func fields(names string) obj { + p := obj{} + for _, name := range strings.Fields(names) { + switch { + case name == "id" || strings.HasSuffix(name, "Id"): + p[name] = uuid() + case strings.HasSuffix(name, "At"): + p[name] = timestamp() + case name == "version" || name == "runtimeGeneration" || name == "admissionEpoch" || name == "generation" || name == "controllerEpoch" || name == "protocolVersion" || name == "idleAdmissionEpoch" || name == "layoutVersion" || name == "reconciledEpoch": + p[name] = number() + case name == "admissionOpen" || name == "initialized": + p[name] = boolean() + default: + p[name] = str() + } + } + return p +} + +func resource(names, nullableNames string) obj { + p := fields(names) + for _, n := range strings.Fields(nullableNames) { + p[n] = optional(asObject(p[n])) + } + return object(p, strings.Fields(names)...) +} + +// Document returns complete schemas and operations. cmd/openapi writes its reviewable JSON artifact. +func Document() map[string]any { + s := obj{} + s["Error"] = object(obj{"code": str(), "params": obj{"type": "object", "additionalProperties": true}, "requestId": uuid()}, "code", "params", "requestId") + s["User"] = resource("id displayName status version createdAt deletedAt", "deletedAt") + s["Tenant"] = resource("id name status role", "") + s["Member"] = resource("tenantId userId role status version createdAt", "") + s["MemberListItem"] = resource("id tenantId userId role status version displayName", "") + s["Project"] = resource("id tenantId ownerUserId name repositoryUrl defaultBranch credentialRefId lifecycle version createdAt deletedAt", "credentialRefId deletedAt") + s["Workspace"] = resource("id tenantId ownerUserId projectId kind desiredState observedState runtimeGeneration version admissionOpen admissionEpoch createdAt deletedAt", "deletedAt") + s["WorkspaceListItem"] = resource("id tenantId ownerUserId projectId kind desiredState observedState runtimeGeneration version admissionOpen admissionEpoch createdAt deletedAt branchName baseCommitId title", "deletedAt baseCommitId title") + s["AdminResource"] = resource("id projectId ownerUserId kind desiredState observedState runtimeGeneration version", "") + s["AdminOperation"] = resource("id tenantId projectId workspaceId kind state step version createdAt updatedAt", "workspaceId") + s["OperationRequest"] = object(obj{"previous": obj{"type": "object", "additionalProperties": ref("Workspace")}}) + s["OperationResult"] = object(obj{"resourceId": uuid()}) + s["Operation"] = resource("id tenantId actorUserId projectId workspaceId kind state step request result errorCode idempotencyKey requestHash controllerEpoch retryAt version createdAt updatedAt", "workspaceId errorCode controllerEpoch retryAt") + opProps := properties(s, "Operation") + opProps["request"] = ref("OperationRequest") + opProps["result"] = ref("OperationResult") + opProps["state"] = enumeration("queued", "running", "retry_wait", "blocked", "succeeded", "failed") + opProps["step"] = enumeration("storage", "worktree", "sandbox", "node", "ready", "quiesce", "terminate", "cleanup", "storage_delete", "done") + for _, name := range []string{"Workspace", "WorkspaceListItem", "AdminResource"} { + p := properties(s, name) + p["kind"] = enumeration("main", "isolated") + p["desiredState"] = enumeration("running", "stopped", "deleted") + p["observedState"] = enumeration("provisioning", "starting", "ready", "stopping", "stopped", "unavailable", "deleting", "deleted") + } + s["Lease"] = resource("name holderId epoch expiresAt", "") + properties(s, "Lease")["holderId"] = str() + properties(s, "Lease")["epoch"] = number() + s["Storage"] = resource("projectId substrateStorageId storageProfile layoutVersion observedState version", "substrateStorageId") + properties(s, "Storage")["substrateStorageId"] = optional(str()) + s["Sandbox"] = resource("id workspaceId generation substrateSandboxId observedState createdAt terminatedAt version", "substrateSandboxId terminatedAt") + properties(s, "Sandbox")["substrateSandboxId"] = optional(str()) + s["Node"] = resource("id sandboxInstanceId serviceSubject connectionState protocolVersion initialized lastSeenAt endedAt idleAdmissionEpoch version workspaceId", "endedAt idleAdmissionEpoch") + s["Ticket"] = resource("id tenantId workspaceId nodeInstanceId actorUserId admissionEpoch kind state createdAt finishedAt version", "finishedAt") + s["EffectRequest"] = object(obj{"kind": enumeration("storage_ensure", "worktree_ensure", "sandbox_ensure", "sandbox_terminate", "worktree_delete", "storage_delete"), "projectId": uuid(), "workspaceId": uuid(), "repositoryUrl": str(), "requestedRef": str(), "sandboxInstanceId": uuid()}, "kind", "projectId") + s["EffectResult"] = object(obj{"layoutVersion": number(), "commitId": obj{"type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$"}, "jobTerminated": boolean(), "removed": boolean(), "terminated": boolean(), "sandboxInstanceId": uuid(), "nodeId": uuid()}) + s["Effect"] = resource("id operationId projectId workspaceId kind state externalId request result reconciledEpoch createdAt version", "workspaceId externalId") + ep := properties(s, "Effect") + ep["externalId"] = optional(str()) + ep["request"] = ref("EffectRequest") + ep["result"] = ref("EffectResult") + s["ControllerProject"] = resource("id tenantId ownerUserId name repositoryUrl defaultBranch credentialRefId lifecycle version createdAt deletedAt secretRef", "credentialRefId deletedAt secretRef") + s["ControllerWorkspace"] = resource("id tenantId ownerUserId projectId kind desiredState observedState runtimeGeneration version admissionOpen admissionEpoch createdAt deletedAt relativePath branchName requestedRef baseCommitId", "deletedAt baseCommitId") + for _, name := range []string{"WorkspaceListItem", "ControllerWorkspace"} { + properties(s, name)["baseCommitId"] = optional(obj{"type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$"}) + } + s["Snapshot"] = object(obj{"operation": ref("Operation"), "project": ref("ControllerProject"), "storage": ref("Storage"), "workspaces": array(ref("ControllerWorkspace")), "sandboxes": array(ref("Sandbox")), "nodes": array(ref("Node")), "effects": array(ref("Effect"))}, "operation", "project", "storage", "workspaces", "sandboxes", "nodes", "effects") + s["EmptyClaim"] = object(obj{"operation": obj{"type": "object", "nullable": true, "enum": []any{nil}}}, "operation") + s["Access"] = object(obj{"userId": uuid(), "tenantId": uuid(), "workspaceId": uuid(), "allowedAction": enumeration("read", "execute"), "executable": boolean(), "runtimeGeneration": number()}, "userId", "tenantId", "workspaceId", "allowedAction", "executable", "runtimeGeneration") + s["IdleRefusal"] = object(obj{"accepted": boolean(), "errorCode": enumeration("resource_in_use")}, "accepted", "errorCode") + paths := obj{} + for _, r := range router.Routes() { + path := r.Path + parameters := []any{} + for _, p := range strings.Split(path, "/") { + if strings.HasPrefix(p, ":") { + name := p[1:] + path = strings.ReplaceAll(path, p, "{"+name+"}") + parameters = append(parameters, obj{"name": name, "in": "path", "required": true, "schema": uuid()}) + } + } + public := r.Action == "" + security := []any{obj{"serviceCredential": []string{}}} + if public || r.Action == "access" || r.Action == "admit" { + security = []any{obj{"serviceCredential": []string{}, "userCredential": []string{}}} + } + description := description(r) + response, status := responseSchema(r) + responses := obj{status: obj{"description": "Successful command or resource response", "content": obj{"application/json": obj{"schema": response}}}} + for _, code := range []string{"400", "401", "403", "404", "409", "428", "500"} { + responses[code] = obj{"description": errorDescription(code), "content": obj{"application/json": obj{"schema": ref("Error")}}} + } + operation := obj{"operationId": strings.ToLower(r.Method) + strings.NewReplacer("/", "_", ":", "").Replace(r.Path), "summary": summary(r), "description": description, "security": security, "responses": responses} + if public && (r.Method == "POST" || r.Method == "DELETE") { + parameters = append(parameters, obj{"name": "Idempotency-Key", "in": "header", "required": true, "schema": obj{"type": "string", "minLength": 1, "maxLength": 200}, "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409."}) + } + if isList(r) { + parameters = append(parameters, obj{"name": "limit", "in": "query", "schema": obj{"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}, obj{"name": "after", "in": "query", "schema": uuid(), "description": "Exclusive UUID cursor, ascending stable ordering."}) + } + if len(parameters) > 0 { + operation["parameters"] = parameters + } + if r.Method != "GET" { + properties := obj{} + required := []string{} + for _, name := range r.Fields { + properties[name] = inputSchema(name, r) + if !optionalField(name, r) { + required = append(required, name) + } + } + operation["requestBody"] = obj{"required": true, "content": obj{"application/json": obj{"schema": object(properties, required...)}}} + } + if paths[path] == nil { + paths[path] = obj{} + } + asObject(paths[path])[strings.ToLower(r.Method)] = operation + } + paths["/healthz"] = obj{"get": obj{"operationId": "health", "summary": "PostgreSQL readiness", "responses": obj{"200": obj{"description": "Database reachable", "content": obj{"application/json": obj{"schema": object(obj{"status": enumeration("ok")}, "status")}}}, "503": obj{"description": "Database unavailable", "content": obj{"application/json": obj{"schema": ref("Error")}}}}}} + return obj{"openapi": "3.0.3", "info": obj{"title": "Ora Cloud phase one", "version": "1.0.0", "description": "Authoritative PostgreSQL core. Simulation is separate; no production Controller/Node/Kubernetes implementation is implied."}, "servers": []any{obj{"url": "http://localhost:8080"}}, "paths": paths, "components": obj{"schemas": s, "securitySchemes": obj{"serviceCredential": obj{"type": "http", "scheme": "bearer", "bearerFormat": "EdDSA JWT", "description": "Pinned issuer/kid/kind=service/role, aud=ora-cloud, exp and iat required, <=5 minute lifetime. Public API requires gateway; internal control requires controller; nodes require scoped node role."}, "userCredential": obj{"type": "apiKey", "in": "header", "name": "X-Ora-User-Token", "description": "Separately signed EdDSA JWT: kind=user, source+sub, caller must equal authenticated service sub, aud=ora-cloud. User and membership status checked in PostgreSQL."}}}} +} + +func isList(r router.Route) bool { + return r.Method == "GET" && (strings.HasSuffix(r.Path, "/tenants") || strings.HasSuffix(r.Path, "/members") || strings.HasSuffix(r.Path, "/projects") || strings.HasSuffix(r.Path, "/workspaces") || strings.HasSuffix(r.Path, "/resource-status")) +} + +func responseSchema(r router.Route) (schema obj, status string) { + if r.Action != "" { + switch r.Action { + case "access": + return ref("Access"), "200" + case "admit", "node_finish": + return ref("Ticket"), "200" + case "node_register", "node_status": + return ref("Node"), "200" + case "node_idle": + return obj{"oneOf": []any{ref("Node"), ref("IdleRefusal")}}, "200" + case "lease_acquire", "lease_renew", "lease_release": + return ref("Lease"), "200" + case "claim": + return obj{"oneOf": []any{ref("Snapshot"), ref("EmptyClaim")}}, "200" + case "snapshot": + return ref("Snapshot"), "200" + case "plan", "effect_result": + return object(obj{"effect": ref("Effect"), "operation": ref("Operation")}, "effect", "operation"), "200" + default: + return ref("Operation"), "200" + } + } + name := "Project" + switch { + case r.Path == "/api/v1/me": + name = "User" + case strings.HasSuffix(r.Path, "/tenants"): + name = "Tenant" + case strings.Contains(r.Path, "/members"): + name = "Member" + if r.Method == "GET" { + name = "MemberListItem" + } + case strings.Contains(r.Path, "/operations"): + name = "Operation" + case strings.HasSuffix(r.Path, "/resource-status"): + name = "AdminResource" + case strings.HasSuffix(r.Path, "/administrative-stop"): + name = "AdminResource" + case strings.Contains(r.Path, "/workspaces"): + name = "Workspace" + if isList(r) { + name = "WorkspaceListItem" + } + } + if isList(r) { + return object(obj{"items": array(ref(name)), "nextCursor": str()}, "items", "nextCursor"), "200" + } + if r.Method == "GET" || r.Method == "PATCH" || r.Method == "PUT" { + if name == "Operation" { + return obj{"oneOf": []any{ref("Operation"), ref("AdminOperation")}}, "200" + } + return ref(name), "200" + } + operation := ref("Operation") + if name == "AdminResource" { + operation = ref("AdminOperation") + } + if strings.HasSuffix(r.Path, "/retry") { + return object(obj{"operation": obj{"oneOf": []any{ref("Operation"), ref("AdminOperation")}}}, "operation"), "202" + } + properties := obj{"resource": ref(name), "operation": operation} + required := []string{"resource", "operation"} + if strings.HasSuffix(r.Path, "/projects") && r.Method == "POST" { + properties["workspace"] = ref("Workspace") + required = append(required, "workspace") + } + return object(properties, required...), "202" +} + +func optionalField(name string, r router.Route) bool { + return name == "defaultBranch" || name == "credentialRefId" || name == "version" && r.Method == "PUT" || name == "epoch" && r.Action == "access" || name == "workspaceId" && r.Action == "plan" || name == "externalId" && r.Action == "effect_result" +} + +func inputSchema(name string, r router.Route) obj { + switch name { + case "version", "epoch", "admissionEpoch": + return obj{"type": "integer", "format": "int64", "minimum": 0} + case "retrySeconds": + return obj{"type": "integer", "minimum": 1, "maximum": 3600} + case "protocolVersion": + return obj{"type": "integer", "enum": []int{1}} + case "initialized", "idle": + return boolean() + case "result": + return ref("EffectResult") + case "action": + return enumeration("read", "execute") + case "role": + return enumeration("admin", "member") + case "status": + return enumeration("active", "disabled") + case "connectionState": + return enumeration("connected", "disconnected") + case "state": + if r.Action == "defer" { + return enumeration("blocked", "retry_wait") + } + return enumeration("running", "succeeded", "failed", "absent") + case "kind": + if r.Action == "admit" { + return enumeration("task", "interaction") + } + return enumeration("storage_ensure", "worktree_ensure", "sandbox_ensure", "sandbox_terminate", "worktree_delete", "storage_delete") + case "errorCode": + return enumeration("substrate_timeout", "termination_unconfirmed", "git_cleanup_failed", "node_unavailable", "external_failure") + case "tenantId", "operationId", "ticketId", "credentialRefId": + return uuid() + case "workspaceId": + if r.Action == "plan" { + return str() + } + return uuid() + } + return str() +} + +func summary(r router.Route) string { + if r.Action != "" { + return strings.ReplaceAll(r.Action, "_", " ") + } + return r.Method + " " + r.Path +} + +func description(r router.Route) string { + base := "Public requests require a gateway service credential plus a caller-bound user credential. Tenant membership is checked before lookup; resource reads filter tenant and owner in SQL. " + if r.Action != "" { + base = "Controller requests require an independent controller service credential; holder, active database-time lease epoch and operation version are checked. " + } + switch r.Action { + case "access": + return "Checks final user, active membership, tenant and owner. read checks ownership; execute additionally requires current controller lease epoch, open admission, ready workspace and a fresh initialized Node. This lookup is not an execution reservation; use admissions." + case "admit": + return "Atomically reserves an active task/interaction ticket on the current Node under the same transaction lock as stop/delete. Requires current controller holder+epoch and caller-bound final-user token. Unknown/uncompleted tickets remain active; bound Node explicitly finishes them. Repeated ticket UUID with identical scope returns it while admission remains open." + case "lease_acquire", "lease_renew", "lease_release": + return "Controller subject is holderId. Global lease lasts 30 seconds using PostgreSQL clock_timestamp(); renew every 10 seconds. Expired acquisition increments epoch, active same-holder acquisition returns current lease. Release and renew require exact live holder+epoch." + case "claim": + return base + "Claims queued/due retry/any running operation; reclaiming with the same epoch increments the operation version and fences stale in-memory workers. Returns a full scoped recovery snapshot. Reconcile every existing effect with Substrate by stable ID before planning or advancing. No automatic prompt replay." + case "plan": + return base + "Only the effect kind appropriate to the current step is allowed. Scope is restricted to operation workspaces. Plan persists BEFORE dispatch; sandbox plan atomically increments generation and allocates a unique live instance. Old instance must be confirmed terminated. Same plan returns the same effect ID." + case "effect_result": + return base + "Reports/reconciles one scoped external effect. External ID cannot change; succeeded evidence is immutable. absent is allowed only for a planned effect. Worktree success requires real commitId and jobTerminated; cleanup requires removed and jobTerminated; termination requires terminated; storage requires layoutVersion=1; sandbox requires its preallocated instance ID. This endpoint trusts the authenticated controller's Substrate observation, not client-supplied status." + case "advance": + return base + "Derives the next step server-side. Requires current-epoch successful effects. quiesce requires all tickets finished and fresh exact-epoch idle proof from each live Node. node step atomically commits worktree readiness, Workspace Ready/admission, and operation success after fresh initialized current Node. Cleanup and storage deletion cannot complete before termination confirmation." + case "defer": + return base + "Preserves operation/effect/resource references and current step; sets blocked or retry_wait with bounded retry delay. Never reports cleanup success on timeout." + case "node_register", "node_status", "node_idle", "node_finish": + return "Requires node service credential whose sub is a process UUID and whose workspaceId/sandboxId/generation match the current unterminated instance. Node identity cannot be replaced while live. Status/idle use Node version; ticket finish uses Ticket version and a completed replay is idempotent. initialized cannot regress. Idle is scoped to operationId and exact Workspace admissionEpoch; true requires no active tickets. false fails that quiesce operation with resource_in_use and restores original admission. Registration requires protocolVersion=1; Pod Running alone cannot make Ready." + } + if strings.Contains(r.Path, "members") { + base += "Administrator only. Updating an existing membership requires matching version; new membership uses version=0. Last effective administrator cannot be disabled/demoted, including concurrent changes. " + } + if strings.Contains(r.Path, "resource-status") || strings.Contains(r.Path, "administrative-stop") { + base += "Administrator response explicitly excludes repository URL, worktree details, credentials, execution output and operation request/result/error details. Administrative stop still requires idle evidence. " + } + if strings.Contains(r.Path, "operations") { + base += "Operation lookup follows project owner; administrative-stop actor receives only the restricted projection. Retry only accepts blocked/retry_wait, exact operation version, and an idempotency key. " + } + if r.Method == "PATCH" { + base += "Only project name may change; version must match. " + } + if r.Method == "DELETE" || strings.HasSuffix(r.Path, "/stop") { + base += "Requires matching resource version and no active project operation. Atomically closes new execution admission. Active tickets return 409 resource_in_use without changing admission. Unknown Node activity requires later proof and remains pending/blocked. main Workspace cannot be independently deleted. " + } + if strings.HasSuffix(r.Path, "/projects") && r.Method == "POST" { + base += "Creates Project/storage/main Workspace/operation atomically. repositoryUrl allows HTTPS or SSH with no password/query/fragment. defaultBranch defaults to HEAD; credentialRefId must belong to tenant and owner. Storage/worktree/sandbox initialization is asynchronous. " + } + if strings.HasSuffix(r.Path, "/workspaces") && r.Method == "POST" { + base += "Creates one isolated Workspace and Task display identity. title/baseRef required; branch and relative path are server-generated. " + } + return base + "Mutation version conflicts return 409; a missing required version returns 428. Unknown fields are rejected. Lists use ascending UUID pagination." +} + +func errorDescription(code string) string { + switch code { + case "400": + return "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + case "401": + return "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + case "403": + return "Disabled user, inactive/missing membership, wrong service role, or admin required" + case "404": + return "Resource absent or outside authorized tenant/owner scope" + case "409": + return "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, or last_admin" + case "428": + return "Version precondition required" + default: + return "Internal error; no SQL or secret details are exposed" + } +} diff --git a/internal/contract/openapi_test.go b/internal/contract/openapi_test.go new file mode 100644 index 0000000..a476a23 --- /dev/null +++ b/internal/contract/openapi_test.go @@ -0,0 +1,32 @@ +package contract + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestPublishedOpenAPIIsValidAndCurrent(t *testing.T) { + published, e := os.ReadFile("../../api/openapi.json") + if e != nil { + t.Fatal(e) + } + generated, e := json.MarshalIndent(Document(), "", " ") + if e != nil { + t.Fatal(e) + } + if string(published) != string(append(generated, '\n')) { + t.Fatal("api/openapi.json is stale; go run ./cmd/openapi") + } + loader := openapi3.NewLoader() + doc, e := loader.LoadFromData(published) + if e != nil { + t.Fatal(e) + } + if e = doc.Validate(context.Background()); e != nil { + t.Fatal(e) + } +} diff --git a/internal/core/README.md b/internal/core/README.md new file mode 100644 index 0000000..c3460a8 --- /dev/null +++ b/internal/core/README.md @@ -0,0 +1,46 @@ +# internal/core: Authoritative Domain Engine + +`internal/core` is the authoritative domain and state-machine layer of Ora Cloud. It owns all business aggregates, state transitions, transaction boundaries, cryptographic token verification, and PostgreSQL persistence orchestration. + +## Module map + +- [migrations](migrations/README.md) defines the forward-only, linear PostgreSQL schema migration scripts and checksum verification. + +## Architecture and runtime model + +### Aggregates and relationships +- **Users & Identities**: Users are identified by stable IdP claims (`source`, `subject`). User creation is tied to first authenticated access or administrative bootstrap. +- **Tenants & Memberships**: Tenants isolate organizational boundaries. Users belong to tenants with either `admin` or `member` roles. +- **Projects**: Owned by `(tenant_id, owner_user_id)`. Each project has an associated repository URL and default branch, linked to a single `project_storage` row. +- **Workspaces & Tasks**: Each project has at most one active `main` workspace (enforced by the `one_main` partial unique index). Additional workspaces are `isolated` and map 1:1 with `tasks`. +- **Operations & Effects**: Mutations (such as project creation, workspace start/stop, or deletion) execute as durable `operations` (`queued`, `running`, `retry_wait`, `blocked`, `done`, `failed`). Operations decompose into durable `effects` representing external tasks executed by Substrate and Controller. +- **Nodes & Sessions**: `workspace_nodes` represent active execution containers bound to a workspace. `sessions` track user conversational threads. + +### Concurrency and locking +- **Transactional advisory lock**: Phase-one serializes domain mutations using PostgreSQL's `SELECT pg_advisory_xact_lock(67420911)` within `Store.transact`. This eliminates race conditions during aggregate state transitions while keeping locking database-local. +- **Single active operation per project**: Enforced by `idleProject`: a new project operation cannot be scheduled if another operation is currently `queued`, `running`, `retry_wait`, or `blocked`. +- **Optimistic concurrency**: Mutations on mutable entities require an explicit `version` parameter. A missing version returns `428 version_required`; a mismatched version returns `409 version_conflict`. +- **Controller leases**: Controller workers acquire exclusive leases via `/internal/v1/controller-lease/acquire`, renewed periodically. Work dispatching uses monotonic `epoch` fencing to reject stale controller instances. + +### Idempotency +- Requests modifying state accept an optional `Idempotency-Key` header scoped to `(tenant_id, user_id)`. +- Requests compute a SHA256 hash of the method, path, and normalized body. +- An identical request replaying an existing key returns the previously stored HTTP response. +- A differing request using the same key is rejected with `409 idempotency_conflict`. + +### Authentication and trust +- `Authenticator` validates JWT tokens using Ed25519/RS256 public keys loaded at startup: + - **Service tokens**: Carry `kind="service"` and `role` (`gateway`, `controller`, or `node`). + - **User tokens**: Carry `kind="user"` and are forwarded in the `X-Ora-User-Token` header by the gateway. The router ensures `user.Caller == service.Subject`. + +### Error handling +- The domain exclusively raises `*Fault` values containing a machine-readable `Code`, dynamic `Params`, and an HTTP `Status`. +- `ErrorCode(err)` converts domain and internal database errors into client-safe `Fault` objects, mapping unexpected errors to `500 internal_error` without leaking database schema or credential information. + +## Boundaries and invariants + +- **No long-lived transactions**: Transactions must never encompass network calls, Git operations, Substrate calls, or child process execution. +- **No in-memory state**: All state transitions must be committed to PostgreSQL before returning success. +- **Tenant isolation**: Every query enforces `tenant_id` and `owner_user_id` filtering. Cross-tenant leakage is prevented at the SQL constraint level. + +See [Database migrations](migrations/README.md), [Core contract](../../docs/core-contract.md), and [Authentication](../../docs/authentication.md). diff --git a/internal/core/auth.go b/internal/core/auth.go new file mode 100644 index 0000000..39c857e --- /dev/null +++ b/internal/core/auth.go @@ -0,0 +1,104 @@ +package core + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims is the only accepted internal identity format. User claims bind the calling service. +type Claims struct { + jwt.RegisteredClaims + Kind string `json:"kind"` + Role string `json:"role,omitempty"` + Caller string `json:"caller,omitempty"` + Source string `json:"source,omitempty"` + DisplayName string `json:"displayName,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + SandboxID string `json:"sandboxId,omitempty"` + Generation int64 `json:"generation,omitempty"` +} + +// TrustedKey pins issuer, purpose, and service role as well as the signing key. +type ( + TrustedKey struct { + ID string `mapstructure:"id"` + Issuer string `mapstructure:"issuer"` + Kind string `mapstructure:"kind"` + Role string `mapstructure:"role"` + PublicKeyFile string `mapstructure:"public_key_file"` + Key ed25519.PublicKey `mapstructure:"-"` + } + // Authenticator verifies Ed25519 credentials; it cannot issue credentials. + Authenticator struct { + Keys map[string]TrustedKey + Audience string + Now func() time.Time + } +) + +func NewAuthenticator(audience string, keys []TrustedKey) (*Authenticator, error) { + if audience == "" || len(keys) == 0 { + return nil, fmt.Errorf("audience and trusted keys required") + } + a := &Authenticator{Keys: map[string]TrustedKey{}, Audience: audience, Now: time.Now} + for _, k := range keys { + if k.ID == "" || k.Issuer == "" || (k.Kind != "service" && k.Kind != "user") { + return nil, fmt.Errorf("invalid trusted key configuration") + } + if _, ok := a.Keys[k.ID]; ok { + return nil, fmt.Errorf("duplicate key id") + } + if len(k.Key) == 0 { + b, e := os.ReadFile(k.PublicKeyFile) + if e != nil { + return nil, e + } + block, _ := pem.Decode(b) + if block == nil { + return nil, fmt.Errorf("invalid public PEM") + } + v, e := x509.ParsePKIXPublicKey(block.Bytes) + if e != nil { + return nil, e + } + var ok bool + k.Key, ok = v.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("Ed25519 key required") + } + } + a.Keys[k.ID] = k + } + return a, nil +} + +func (a *Authenticator) Verify(raw, kind string) (*Claims, error) { + c := &Claims{} + token, e := jwt.ParseWithClaims(raw, c, func(token *jwt.Token) (any, error) { + id, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("kid required") + } + k, ok := a.Keys[id] + if !ok || k.Kind != kind || c.Kind != kind || k.Issuer != c.Issuer || (kind == "service" && k.Role != c.Role) { + return nil, fmt.Errorf("untrusted credential") + } + return k.Key, nil + }, jwt.WithValidMethods([]string{"EdDSA"}), jwt.WithAudience(a.Audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(a.Now)) + if e != nil || token == nil || !token.Valid || c.Subject == "" || c.IssuedAt == nil || c.ExpiresAt == nil { + return nil, fmt.Errorf("invalid credential") + } + if c.ExpiresAt.Sub(c.IssuedAt.Time) > 5*time.Minute || !c.ExpiresAt.After(c.IssuedAt.Time) || c.IssuedAt.After(a.Now()) { + return nil, fmt.Errorf("invalid credential lifetime") + } + if kind == "user" && (c.Source == "" || c.Caller == "") { + return nil, fmt.Errorf("identity and caller binding required") + } + return c, nil +} diff --git a/internal/core/control.go b/internal/core/control.go new file mode 100644 index 0000000..d24d7cb --- /dev/null +++ b/internal/core/control.go @@ -0,0 +1,337 @@ +package core + +import ( + "context" + "strings" +) + +// ControlRequest contains a verified service principal, never a header-selected role. +type ControlRequest struct { + Action, OperationID, EffectID, WorkspaceID, TicketID string + Body Object + Service *Claims + Identity *Claims +} + +// Control is the finite internal command API. Controllers have no table-write or SQL interface. +func (s *Store) Control(ctx context.Context, r *ControlRequest) (Object, error) { + return s.transact(ctx, func(t *transaction) Object { + if r.Action == "access" || r.Action == "admit" { + require(r.Service.Role == "controller", 403, "service_forbidden") + if r.Action == "admit" || r.Body.S("action") == "execute" { + leaseValid(t, r) + } + return access(t, r) + } + if strings.HasPrefix(r.Action, "node_") { + require(r.Service.Role == "node", 403, "service_forbidden") + return nodeCommand(t, r) + } + require(r.Service.Role == "controller", 403, "service_forbidden") + if strings.HasPrefix(r.Action, "lease_") { + return lease(t, r) + } + leaseValid(t, r) + if r.Action == "claim" { + return claim(t, r) + } + o := operation(t, r) + switch r.Action { + case "snapshot": + return snapshot(t, o) + case "plan": + return planEffect(t, r, o) + case "effect_result": + return effectResult(t, r, o) + case "advance": + return advance(t, r, o) + case "defer": + state := r.Body.S("state") + code := r.Body.S("errorCode") + require(state == "blocked" || state == "retry_wait", 400, "invalid_operation_state") + require(code == "substrate_timeout" || code == "termination_unconfirmed" || code == "git_cleanup_failed" || code == "node_unavailable" || code == "external_failure", 400, "invalid_error_code") + delay := r.Body.N("retrySeconds") + require(delay >= 1 && delay <= 3600, 400, "invalid_retry_delay") + t.exec("UPDATE operations SET state=$2,error_code=$3,retry_at=clock_timestamp()+($4 * interval '1 second'),version=version+1,updated_at=now() WHERE id=$1", o.S("id"), state, code, delay) + return t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) + default: + reject(404, "not_found") + } + return nil + }) +} + +func lease(t *transaction, r *ControlRequest) Object { + old := t.one("SELECT *,expires_at>clock_timestamp() AS valid FROM controller_leases WHERE name='global'") + if r.Action == "lease_acquire" { + switch { + case old == nil: + t.exec("INSERT INTO controller_leases(name,holder_id,epoch,expires_at) VALUES('global',$1,1,clock_timestamp()+interval '30 seconds')", r.Service.Subject) + case old.B("valid"): + require(old.S("holderId") == r.Service.Subject, 409, "lease_held") + default: + t.exec("UPDATE controller_leases SET holder_id=$1,epoch=epoch+1,expires_at=clock_timestamp()+interval '30 seconds' WHERE name='global'", r.Service.Subject) + } + } else { + leaseValid(t, r) + if r.Action == "lease_renew" { + t.exec("UPDATE controller_leases SET expires_at=clock_timestamp()+interval '30 seconds' WHERE name='global'") + } else { + require(r.Action == "lease_release", 404, "not_found") + t.exec("UPDATE controller_leases SET expires_at=clock_timestamp() WHERE name='global'") + } + } + return t.one("SELECT * FROM controller_leases WHERE name='global'") +} + +func leaseValid(t *transaction, r *ControlRequest) { + l := t.one("SELECT * FROM controller_leases WHERE name='global' AND holder_id=$1 AND epoch=$2 AND expires_at>clock_timestamp()", r.Service.Subject, r.Body.N("epoch")) + require(l != nil, 409, "stale_controller") +} + +func claim(t *transaction, r *ControlRequest) Object { + o := t.one("SELECT * FROM operations WHERE state='queued' OR (state='retry_wait' AND retry_at<=clock_timestamp()) OR state='running' ORDER BY created_at,id LIMIT 1") + if o == nil { + return Object{"operation": nil} + } + t.exec("UPDATE operations SET state='running',controller_epoch=$2,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), r.Body.N("epoch")) + o = t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) + return snapshot(t, o) +} + +func operation(t *transaction, r *ControlRequest) Object { + require(validID(r.OperationID), 404, "not_found") + o := t.one("SELECT * FROM operations WHERE id=$1", r.OperationID) + require(o != nil, 404, "not_found") + require(o.S("state") == "running" && o.N("controllerEpoch") == r.Body.N("epoch"), 409, "stale_operation") + version(o, r.Body.N("version")) + return o +} + +func snapshot(t *transaction, o Object) Object { + p := t.one("SELECT p.*,c.secret_ref FROM projects p LEFT JOIN credential_refs c ON c.id=p.credential_ref_id WHERE p.id=$1", o.S("projectId")) + return Object{"operation": o, "project": p, "storage": t.one("SELECT * FROM project_storage WHERE project_id=$1", o.S("projectId")), "workspaces": t.list("SELECT w.*,wt.relative_path,wt.branch_name,wt.requested_ref,wt.base_commit_id FROM workspaces w JOIN workspace_worktrees wt ON wt.workspace_id=w.id WHERE w.project_id=$1 AND w.deleted_at IS NULL ORDER BY w.id", o.S("projectId")), "sandboxes": t.list("SELECT s.* FROM sandbox_instances s JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 ORDER BY s.id", o.S("projectId")), "nodes": t.list("SELECT n.* FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 ORDER BY n.id", o.S("projectId")), "effects": t.list("SELECT * FROM external_effects WHERE operation_id=$1 ORDER BY created_at,id", o.S("id"))} +} + +func operationWorkspaces(t *transaction, o Object) []Object { + if o.S("workspaceId") != "" { + return t.list("SELECT * FROM workspaces WHERE id=$1", o.S("workspaceId")) + } + return t.list("SELECT * FROM workspaces WHERE project_id=$1 AND deleted_at IS NULL ORDER BY id", o.S("projectId")) +} + +func reconciled(t *transaction, o Object) { + require(t.one("SELECT id FROM external_effects WHERE operation_id=$1 AND reconciled_epoch<>$2", o.S("id"), o.N("controllerEpoch")) == nil, 409, "reconcile_required") +} + +func effectFor(t *transaction, oid, kind, wid string) Object { + return t.one("SELECT * FROM external_effects WHERE operation_id=$1 AND kind=$2 AND workspace_id IS NOT DISTINCT FROM $3::uuid", oid, kind, nullable(wid)) +} + +func nullable(s string) any { + if s == "" { + return nil + } + return s +} + +func planEffect(t *transaction, r *ControlRequest, o Object) Object { + reconciled(t, o) + kind, wid := r.Body.S("kind"), r.Body.S("workspaceId") + allowed := map[string]string{"storage": "storage_ensure", "worktree": "worktree_ensure", "sandbox": "sandbox_ensure", "terminate": "sandbox_terminate", "cleanup": "worktree_delete", "storage_delete": "storage_delete"} + require(allowed[o.S("step")] == kind, 409, "invalid_step") + if kind == "storage_ensure" || kind == "storage_delete" { + require(wid == "", 400, "invalid_effect_scope") + } else { + require(validID(wid), 400, "invalid_effect_scope") + found := false + for _, w := range operationWorkspaces(t, o) { + if w.S("id") == wid { + found = true + } + } + require(found, 403, "invalid_effect_scope") + } + if existing := effectFor(t, o.S("id"), kind, wid); existing != nil { + return Object{"effect": existing, "operation": o} + } + id := newID() + request := Object{"kind": kind, "projectId": o.S("projectId")} + if wid != "" { + request["workspaceId"] = wid + } + if kind == "worktree_ensure" { + project := t.one("SELECT repository_url FROM projects WHERE id=$1", o.S("projectId")) + worktree := t.one("SELECT requested_ref FROM workspace_worktrees WHERE workspace_id=$1", wid) + request["repositoryUrl"] = project.S("repositoryUrl") + request["requestedRef"] = worktree.S("requestedRef") + } + if kind == "sandbox_ensure" { + w := t.one("SELECT * FROM workspaces WHERE id=$1", wid) + require(w.S("desiredState") == "running", 409, "resource_unavailable") + require(t.one("SELECT id FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", wid) == nil, 409, "termination_unconfirmed") + t.exec("UPDATE workspaces SET runtime_generation=runtime_generation+1,observed_state='starting',version=version+1 WHERE id=$1", wid) + t.exec("INSERT INTO sandbox_instances(id,workspace_id,generation,observed_state) SELECT $1,id,runtime_generation,'allocating' FROM workspaces WHERE id=$2", id, wid) + } + if kind == "sandbox_terminate" { + s := t.one("SELECT * FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", wid) + require(s != nil, 409, "no_current_sandbox") + request["sandboxInstanceId"] = s.S("id") + t.exec("UPDATE sandbox_instances SET observed_state='terminating' WHERE id=$1", s.S("id")) + } + if kind == "storage_delete" { + require(t.one("SELECT s.id FROM sandbox_instances s JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 AND s.terminated_at IS NULL", o.S("projectId")) == nil, 409, "termination_unconfirmed") + require(t.one("SELECT id FROM external_effects WHERE project_id=$1 AND state IN ('planned','running')", o.S("projectId")) == nil, 409, "maintenance_unconfirmed") + } + t.exec("INSERT INTO external_effects(id,operation_id,project_id,workspace_id,kind,state,request,reconciled_epoch) VALUES($1,$2,$3,$4,$5,'planned',$6,$7)", id, o.S("id"), o.S("projectId"), nullable(wid), kind, jsonText(request), o.N("controllerEpoch")) + t.exec("UPDATE operations SET version=version+1,updated_at=now() WHERE id=$1", o.S("id")) + return Object{"effect": t.one("SELECT * FROM external_effects WHERE id=$1", id), "operation": t.one("SELECT * FROM operations WHERE id=$1", o.S("id"))} +} + +func effectResult(t *transaction, r *ControlRequest, o Object) Object { + require(validID(r.EffectID), 404, "not_found") + e := t.one("SELECT * FROM external_effects WHERE id=$1 AND operation_id=$2", r.EffectID, o.S("id")) + require(e != nil, 404, "not_found") + state := r.Body.S("state") + require(state == "running" || state == "succeeded" || state == "failed" || state == "absent", 400, "invalid_effect_state") + result := r.Body.O("result") + external := r.Body.S("externalId") + if state == "absent" { + require(e.S("state") == "planned", 409, "external_state_conflict") + state = "planned" + } else { + require(external != "" && len(external) <= 200, 400, "external_id_required") + if e.S("externalId") != "" { + require(external == e.S("externalId"), 409, "external_binding_conflict") + } + } + if e.S("state") == "succeeded" { + require(state == "succeeded" && jsonText(result) == jsonText(e.O("result")), 409, "external_state_conflict") + } + if state == "succeeded" { + switch e.S("kind") { + case "worktree_ensure": + require(commitID(result.S("commitId")) && result.B("jobTerminated"), 400, "invalid_worktree_evidence") + case "worktree_delete": + require(result.B("jobTerminated") && result.B("removed"), 400, "invalid_cleanup_evidence") + case "sandbox_terminate": + require(result.B("terminated"), 409, "termination_unconfirmed") + case "storage_delete": + require(result.B("removed"), 400, "invalid_cleanup_evidence") + case "storage_ensure": + require(result.N("layoutVersion") == 1, 400, "invalid_storage_layout") + case "sandbox_ensure": + require(result.S("sandboxInstanceId") == e.S("id"), 400, "invalid_sandbox_evidence") + } + } + t.exec("UPDATE external_effects SET state=$2,external_id=COALESCE($3,external_id),result=$4,reconciled_epoch=$5 WHERE id=$1", e.S("id"), state, nullable(external), jsonText(result), o.N("controllerEpoch")) + t.exec("UPDATE operations SET version=version+1,updated_at=now() WHERE id=$1", o.S("id")) + return Object{"effect": t.one("SELECT * FROM external_effects WHERE id=$1", e.S("id")), "operation": t.one("SELECT * FROM operations WHERE id=$1", o.S("id"))} +} + +func commitID(s string) bool { + if len(s) != 40 && len(s) != 64 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func completedEffect(t *transaction, o Object, kind, wid string) Object { + e := effectFor(t, o.S("id"), kind, wid) + require(e != nil && e.S("state") == "succeeded" && e.N("reconciledEpoch") == o.N("controllerEpoch"), 409, "effect_incomplete") + return e +} + +func advance(t *transaction, r *ControlRequest, o Object) Object { + reconciled(t, o) + next := "" + wid := o.S("workspaceId") + switch o.S("step") { + case "storage": + e := completedEffect(t, o, "storage_ensure", "") + t.exec("UPDATE project_storage SET substrate_storage_id=$2,observed_state='ready',version=version+1 WHERE project_id=$1 AND substrate_storage_id IS NULL", o.S("projectId"), e.S("externalId")) + next = "worktree" + case "worktree": + e := completedEffect(t, o, "worktree_ensure", wid) + t.exec("UPDATE workspace_worktrees SET base_commit_id=$2 WHERE workspace_id=$1", wid, e.O("result").S("commitId")) + next = "sandbox" + case "sandbox": + e := completedEffect(t, o, "sandbox_ensure", wid) + t.exec("UPDATE sandbox_instances SET substrate_sandbox_id=$2,observed_state='starting' WHERE id=$1 AND substrate_sandbox_id IS NULL AND terminated_at IS NULL", e.S("id"), e.S("externalId")) + next = "node" + case "node": + w := t.one("SELECT * FROM workspaces WHERE id=$1", wid) + require(w.S("desiredState") == "running", 409, "resource_unavailable") + n := t.one("SELECT n.id FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id WHERE s.workspace_id=$1 AND s.generation=$2 AND s.terminated_at IS NULL AND n.ended_at IS NULL AND n.initialized AND n.connection_state='connected' AND n.last_seen_at>clock_timestamp()-interval '30 seconds'", wid, w.N("runtimeGeneration")) + require(n != nil, 409, "node_not_ready") + require(t.one("SELECT workspace_id FROM workspace_worktrees WHERE workspace_id=$1 AND base_commit_id IS NOT NULL", wid) != nil, 409, "worktree_not_ready") + t.exec("UPDATE workspace_worktrees SET provisioning_state='ready' WHERE workspace_id=$1", wid) + t.exec("UPDATE sandbox_instances SET observed_state='running' WHERE workspace_id=$1 AND terminated_at IS NULL", wid) + t.exec("UPDATE workspaces SET observed_state='ready',admission_open=true,version=version+1 WHERE id=$1", wid) + t.exec("UPDATE projects SET lifecycle='active',version=version+1 WHERE id=$1 AND lifecycle='provisioning'", o.S("projectId")) + next = "done" + case "quiesce": + for _, w := range operationWorkspaces(t, o) { + checkActivities(t, w) + live := t.one("SELECT id FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", w.S("id")) + if live != nil { + require(t.one("SELECT id FROM node_instances WHERE sandbox_instance_id=$1 AND ended_at IS NULL AND idle_admission_epoch=$2 AND connection_state='connected' AND last_seen_at>clock_timestamp()-interval '30 seconds'", live.S("id"), w.N("admissionEpoch")) != nil, 409, "idle_unconfirmed") + } + } + next = "terminate" + case "terminate": + for _, w := range operationWorkspaces(t, o) { + live := t.one("SELECT id FROM sandbox_instances WHERE workspace_id=$1 AND terminated_at IS NULL", w.S("id")) + if live != nil { + completedEffect(t, o, "sandbox_terminate", w.S("id")) + t.exec("UPDATE node_instances SET ended_at=now(),connection_state='ended',version=version+1 WHERE sandbox_instance_id=$1 AND ended_at IS NULL", live.S("id")) + t.exec("UPDATE sandbox_instances SET observed_state='terminated',terminated_at=now() WHERE id=$1", live.S("id")) + } + } + if o.S("kind") == "stop" || o.S("kind") == "administrative_stop" { + t.exec("UPDATE workspaces SET observed_state='stopped',version=version+1 WHERE id=$1", wid) + next = "done" + } else { + next = "cleanup" + } + case "cleanup": + for _, w := range operationWorkspaces(t, o) { + completedEffect(t, o, "worktree_delete", w.S("id")) + t.exec("UPDATE workspace_worktrees SET provisioning_state='deleted' WHERE workspace_id=$1", w.S("id")) + } + if o.S("kind") == "delete_project" { + t.exec("UPDATE project_storage SET observed_state='deleting',version=version+1 WHERE project_id=$1", o.S("projectId")) + next = "storage_delete" + } else { + deleteWorkspace(t, wid) + next = "done" + } + case "storage_delete": + completedEffect(t, o, "storage_delete", "") + require(t.one("SELECT s.id FROM sandbox_instances s JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 AND s.terminated_at IS NULL", o.S("projectId")) == nil, 409, "termination_unconfirmed") + for _, w := range operationWorkspaces(t, o) { + deleteWorkspace(t, w.S("id")) + } + t.exec("UPDATE project_storage SET observed_state='deleted',version=version+1 WHERE project_id=$1", o.S("projectId")) + t.exec("UPDATE projects SET lifecycle='deleted',deleted_at=now(),version=version+1 WHERE id=$1", o.S("projectId")) + next = "done" + default: + reject(409, "invalid_step") + } + if next == "done" { + t.exec("UPDATE operations SET step='done',state='succeeded',result=jsonb_build_object('resourceId',COALESCE(workspace_id,project_id)),version=version+1,updated_at=now() WHERE id=$1", o.S("id")) + } else { + t.exec("UPDATE operations SET step=$2,version=version+1,updated_at=now() WHERE id=$1", o.S("id"), next) + } + return t.one("SELECT * FROM operations WHERE id=$1", o.S("id")) +} + +func deleteWorkspace(t *transaction, wid string) { + t.exec("UPDATE workspaces SET observed_state='deleted',admission_open=false,deleted_at=now(),version=version+1 WHERE id=$1", wid) + t.exec("UPDATE tasks SET deleted_at=now(),version=version+1 WHERE workspace_id=$1", wid) +} diff --git a/internal/core/migrations/0001_core.sql b/internal/core/migrations/0001_core.sql new file mode 100644 index 0000000..24452d2 --- /dev/null +++ b/internal/core/migrations/0001_core.sql @@ -0,0 +1,124 @@ +CREATE TABLE users ( + id uuid PRIMARY KEY, display_name text NOT NULL DEFAULT '', status text NOT NULL CHECK(status IN ('active','disabled')), + version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz +); +CREATE TABLE user_identities ( + user_id uuid NOT NULL REFERENCES users(id), source text NOT NULL CHECK(length(source) BETWEEN 1 AND 128), + subject text NOT NULL CHECK(length(subject) BETWEEN 1 AND 512), created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(source,subject) +); +CREATE TABLE tenants ( + id uuid PRIMARY KEY, name text NOT NULL CHECK(length(name) BETWEEN 1 AND 200), status text NOT NULL CHECK(status IN ('active','disabled')), + version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz +); +CREATE TABLE tenant_memberships ( + tenant_id uuid NOT NULL REFERENCES tenants(id), user_id uuid NOT NULL REFERENCES users(id), role text NOT NULL CHECK(role IN ('admin','member')), + status text NOT NULL CHECK(status IN ('active','disabled')), version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_id,user_id) +); +CREATE TABLE credential_refs ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL, owner_user_id uuid NOT NULL, purpose text NOT NULL CHECK(purpose='git'), secret_ref text NOT NULL CHECK(length(secret_ref)>0), + version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, + FOREIGN KEY(tenant_id,owner_user_id) REFERENCES tenant_memberships(tenant_id,user_id), UNIQUE(id,tenant_id,owner_user_id) +); +CREATE TABLE projects ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL, owner_user_id uuid NOT NULL, name text NOT NULL CHECK(length(name) BETWEEN 1 AND 200), repository_url text NOT NULL CHECK(length(repository_url)>0), + default_branch text NOT NULL, credential_ref_id uuid, lifecycle text NOT NULL CHECK(lifecycle IN ('provisioning','active','deleting','deleted')), + version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, + FOREIGN KEY(tenant_id,owner_user_id) REFERENCES tenant_memberships(tenant_id,user_id), + FOREIGN KEY(credential_ref_id,tenant_id,owner_user_id) REFERENCES credential_refs(id,tenant_id,owner_user_id), UNIQUE(id,tenant_id,owner_user_id) +); +CREATE TABLE project_storage ( + project_id uuid PRIMARY KEY REFERENCES projects(id), substrate_storage_id text UNIQUE, storage_profile text NOT NULL DEFAULT 'rwx-v1', layout_version integer NOT NULL DEFAULT 1 CHECK(layout_version=1), + observed_state text NOT NULL CHECK(observed_state IN ('pending','ready','deleting','deleted')), version bigint NOT NULL DEFAULT 1 CHECK(version>0) +); +CREATE TABLE workspaces ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL, owner_user_id uuid NOT NULL, project_id uuid NOT NULL, kind text NOT NULL CHECK(kind IN ('main','isolated')), + desired_state text NOT NULL CHECK(desired_state IN ('running','stopped','deleted')), + observed_state text NOT NULL CHECK(observed_state IN ('provisioning','starting','ready','stopping','stopped','unavailable','deleting','deleted')), + runtime_generation bigint NOT NULL DEFAULT 0 CHECK(runtime_generation>=0), version bigint NOT NULL DEFAULT 1 CHECK(version>0), admission_open boolean NOT NULL DEFAULT false, + admission_epoch bigint NOT NULL DEFAULT 0 CHECK(admission_epoch>=0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, + FOREIGN KEY(project_id,tenant_id,owner_user_id) REFERENCES projects(id,tenant_id,owner_user_id), UNIQUE(id,tenant_id,owner_user_id), UNIQUE(id,project_id), + CHECK(NOT admission_open OR (desired_state='running' AND observed_state='ready' AND deleted_at IS NULL)) +); +CREATE UNIQUE INDEX one_main ON workspaces(project_id) WHERE kind='main' AND deleted_at IS NULL; +CREATE INDEX project_list ON projects(tenant_id,owner_user_id,id); +CREATE INDEX workspace_list ON workspaces(tenant_id,owner_user_id,project_id,id); +CREATE TABLE workspace_worktrees ( + workspace_id uuid PRIMARY KEY REFERENCES workspaces(id), relative_path text NOT NULL, branch_name text NOT NULL, requested_ref text NOT NULL, base_commit_id text CHECK(base_commit_id ~ '^([0-9a-f]{40}|[0-9a-f]{64})$'), + provisioning_state text NOT NULL CHECK(provisioning_state IN ('pending','ready','deleting','deleted')), + CHECK(relative_path='workspaces/' || workspace_id::text || '/checkout'), CHECK(branch_name='ora/' || workspace_id::text) +); +CREATE TABLE tasks ( + id uuid PRIMARY KEY, workspace_id uuid NOT NULL UNIQUE REFERENCES workspaces(id), title text NOT NULL CHECK(length(title) BETWEEN 1 AND 200), + version bigint NOT NULL DEFAULT 1 CHECK(version>0), created_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz +); +CREATE TABLE sandbox_instances ( + id uuid PRIMARY KEY, workspace_id uuid NOT NULL REFERENCES workspaces(id), generation bigint NOT NULL CHECK(generation>0), substrate_sandbox_id text UNIQUE, + observed_state text NOT NULL CHECK(observed_state IN ('allocating','starting','running','terminating','terminated')), + created_at timestamptz NOT NULL DEFAULT now(), terminated_at timestamptz, UNIQUE(workspace_id,generation), UNIQUE(id,workspace_id,generation), + CHECK((observed_state='terminated')=(terminated_at IS NOT NULL)) +); +CREATE UNIQUE INDEX one_live_sandbox ON sandbox_instances(workspace_id) WHERE terminated_at IS NULL; +CREATE TABLE node_instances ( + id uuid PRIMARY KEY, sandbox_instance_id uuid NOT NULL REFERENCES sandbox_instances(id), service_subject text NOT NULL, + connection_state text NOT NULL CHECK(connection_state IN ('connected','disconnected','ended')), protocol_version integer NOT NULL CHECK(protocol_version=1), + initialized boolean NOT NULL DEFAULT false, last_seen_at timestamptz NOT NULL DEFAULT now(), ended_at timestamptz, + idle_admission_epoch bigint, version bigint NOT NULL DEFAULT 1 CHECK(version>0) +); +CREATE UNIQUE INDEX one_live_node ON node_instances(sandbox_instance_id) WHERE ended_at IS NULL; +CREATE TABLE controller_leases ( + name text PRIMARY KEY CHECK(name='global'), holder_id text NOT NULL, epoch bigint NOT NULL CHECK(epoch>0), expires_at timestamptz NOT NULL +); +CREATE TABLE operations ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL, actor_user_id uuid NOT NULL, project_id uuid NOT NULL REFERENCES projects(id), workspace_id uuid REFERENCES workspaces(id), + kind text NOT NULL CHECK(kind IN ('create_project','create_workspace','start','stop','delete_workspace','delete_project','administrative_stop')), + state text NOT NULL CHECK(state IN ('queued','running','retry_wait','blocked','succeeded','failed')), step text NOT NULL CHECK(step IN ('storage','worktree','sandbox','node','ready','quiesce','terminate','cleanup','storage_delete','done')), + request jsonb NOT NULL CHECK(jsonb_typeof(request)='object'), result jsonb NOT NULL DEFAULT '{}' CHECK(jsonb_typeof(result)='object'), error_code text, + idempotency_key text NOT NULL, request_hash text NOT NULL, controller_epoch bigint, retry_at timestamptz, version bigint NOT NULL DEFAULT 1 CHECK(version>0), + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), FOREIGN KEY(tenant_id,actor_user_id) REFERENCES tenant_memberships(tenant_id,user_id), + FOREIGN KEY(workspace_id,project_id) REFERENCES workspaces(id,project_id) +); +CREATE UNIQUE INDEX one_project_operation ON operations(project_id) WHERE state IN ('queued','running','retry_wait','blocked'); +CREATE TABLE idempotency_records ( + tenant_id uuid NOT NULL, user_id uuid NOT NULL, key text NOT NULL CHECK(length(key) BETWEEN 1 AND 200), request_hash text NOT NULL, + response jsonb NOT NULL CHECK(jsonb_typeof(response)='object'), status integer NOT NULL CHECK(status BETWEEN 200 AND 299), created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY(tenant_id,user_id,key), FOREIGN KEY(tenant_id,user_id) REFERENCES tenant_memberships(tenant_id,user_id) +); +-- Unknown activity remains active until the bound Node explicitly finishes it. +CREATE TABLE execution_tickets ( + id uuid PRIMARY KEY, workspace_id uuid NOT NULL REFERENCES workspaces(id), node_instance_id uuid NOT NULL REFERENCES node_instances(id), actor_user_id uuid NOT NULL REFERENCES users(id), + admission_epoch bigint NOT NULL, kind text NOT NULL CHECK(kind IN ('task','interaction')), state text NOT NULL CHECK(state IN ('active','finished')), + created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz +); +-- Write-ahead effects survive response loss and lease takeover. +CREATE TABLE external_effects ( + id uuid PRIMARY KEY, operation_id uuid NOT NULL REFERENCES operations(id), project_id uuid NOT NULL REFERENCES projects(id), workspace_id uuid REFERENCES workspaces(id), + kind text NOT NULL CHECK(kind IN ('storage_ensure','worktree_ensure','sandbox_ensure','sandbox_terminate','worktree_delete','storage_delete')), + state text NOT NULL CHECK(state IN ('planned','running','succeeded','failed')), external_id text, result jsonb NOT NULL DEFAULT '{}' CHECK(jsonb_typeof(result)='object'), + reconciled_epoch bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(operation_id,kind,workspace_id) +); +CREATE UNIQUE INDEX singleton_effect ON external_effects(operation_id,kind) WHERE workspace_id IS NULL; +CREATE FUNCTION check_project_main() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE pid uuid; live boolean; n integer; +BEGIN + IF TG_TABLE_NAME='projects' THEN pid:=COALESCE(NEW.id,OLD.id); ELSE pid:=COALESCE(NEW.project_id,OLD.project_id); END IF; + SELECT deleted_at IS NULL INTO live FROM projects WHERE id=pid; + IF live THEN + SELECT count(*) INTO n FROM workspaces WHERE project_id=pid AND kind='main' AND deleted_at IS NULL; + IF n<>1 THEN RAISE EXCEPTION 'live project requires exactly one main workspace' USING ERRCODE='23514'; END IF; + END IF; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER project_main AFTER INSERT OR UPDATE OR DELETE ON projects DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_project_main(); +CREATE CONSTRAINT TRIGGER workspace_main AFTER INSERT OR UPDATE OR DELETE ON workspaces DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_project_main(); +CREATE FUNCTION check_last_admin() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE tid uuid; +BEGIN + tid:=COALESCE(NEW.tenant_id,OLD.tenant_id); + PERFORM 1 FROM tenants WHERE id=tid FOR UPDATE; + IF EXISTS(SELECT 1 FROM tenants WHERE id=tid AND status='active') AND NOT EXISTS( + SELECT 1 FROM tenant_memberships m JOIN users u ON u.id=m.user_id WHERE m.tenant_id=tid AND m.role='admin' AND m.status='active' AND u.status='active' AND u.deleted_at IS NULL) + THEN RAISE EXCEPTION 'last active administrator' USING ERRCODE='23514'; END IF; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER last_admin AFTER INSERT OR UPDATE OR DELETE ON tenant_memberships DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_last_admin(); diff --git a/internal/core/migrations/0002_aggregate_guards.sql b/internal/core/migrations/0002_aggregate_guards.sql new file mode 100644 index 0000000..876d061 --- /dev/null +++ b/internal/core/migrations/0002_aggregate_guards.sql @@ -0,0 +1,37 @@ +ALTER TABLE projects ADD UNIQUE(id,tenant_id); +ALTER TABLE operations ADD FOREIGN KEY(project_id,tenant_id) REFERENCES projects(id,tenant_id); +ALTER TABLE operations ADD UNIQUE(id,project_id); +ALTER TABLE external_effects ADD FOREIGN KEY(operation_id,project_id) REFERENCES operations(id,project_id); +ALTER TABLE external_effects ADD FOREIGN KEY(workspace_id,project_id) REFERENCES workspaces(id,project_id); +ALTER TABLE node_instances ADD COLUMN workspace_id uuid; +UPDATE node_instances n SET workspace_id=s.workspace_id FROM sandbox_instances s WHERE s.id=n.sandbox_instance_id; +ALTER TABLE node_instances ALTER COLUMN workspace_id SET NOT NULL; +ALTER TABLE sandbox_instances ADD UNIQUE(id,workspace_id); +ALTER TABLE node_instances ADD FOREIGN KEY(sandbox_instance_id,workspace_id) REFERENCES sandbox_instances(id,workspace_id); +ALTER TABLE node_instances ADD UNIQUE(id,workspace_id); +ALTER TABLE execution_tickets ADD FOREIGN KEY(node_instance_id,workspace_id) REFERENCES node_instances(id,workspace_id); +CREATE FUNCTION immutable_ownership() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.id<>OLD.id OR NEW.tenant_id<>OLD.tenant_id OR NEW.owner_user_id<>OLD.owner_user_id THEN + RAISE EXCEPTION 'resource ownership is immutable' USING ERRCODE='23514'; END IF; + IF TG_TABLE_NAME='workspaces' AND (to_jsonb(NEW)->>'project_id'<>to_jsonb(OLD)->>'project_id' OR to_jsonb(NEW)->>'kind'<>to_jsonb(OLD)->>'kind') THEN + RAISE EXCEPTION 'workspace aggregate is immutable' USING ERRCODE='23514'; END IF; + RETURN NEW; +END $$; +CREATE TRIGGER immutable_project BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION immutable_ownership(); +CREATE TRIGGER immutable_workspace BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION immutable_ownership(); +CREATE FUNCTION validate_task_kind() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM workspaces WHERE id=NEW.workspace_id AND kind='isolated') THEN + RAISE EXCEPTION 'task requires isolated workspace' USING ERRCODE='23514'; END IF; RETURN NEW; +END $$; +CREATE TRIGGER task_kind BEFORE INSERT OR UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION validate_task_kind(); +CREATE FUNCTION validate_effective_admins() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + PERFORM 1 FROM tenants WHERE status='active' ORDER BY id FOR UPDATE; + IF EXISTS(SELECT 1 FROM tenants t WHERE t.status='active' AND NOT EXISTS( + SELECT 1 FROM tenant_memberships m JOIN users u ON u.id=m.user_id WHERE m.tenant_id=t.id AND m.role='admin' AND m.status='active' AND u.status='active' AND u.deleted_at IS NULL)) THEN + RAISE EXCEPTION 'active tenant requires active administrator' USING ERRCODE='23514'; END IF; RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER tenant_admin AFTER INSERT OR UPDATE ON tenants DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_effective_admins(); +CREATE CONSTRAINT TRIGGER user_admin AFTER UPDATE OR DELETE ON users DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_effective_admins(); diff --git a/internal/core/migrations/0003_resource_versions.sql b/internal/core/migrations/0003_resource_versions.sql new file mode 100644 index 0000000..d716242 --- /dev/null +++ b/internal/core/migrations/0003_resource_versions.sql @@ -0,0 +1,28 @@ +ALTER TABLE workspace_worktrees ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK(version>0); +ALTER TABLE sandbox_instances ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK(version>0); +ALTER TABLE external_effects ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK(version>0); +ALTER TABLE execution_tickets ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK(version>0); +CREATE FUNCTION increment_resource_version() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN NEW.version:=OLD.version+1; RETURN NEW; END $$; +CREATE TRIGGER worktree_version BEFORE UPDATE ON workspace_worktrees FOR EACH ROW EXECUTE FUNCTION increment_resource_version(); +CREATE TRIGGER sandbox_version BEFORE UPDATE ON sandbox_instances FOR EACH ROW EXECUTE FUNCTION increment_resource_version(); +CREATE TRIGGER effect_version BEFORE UPDATE ON external_effects FOR EACH ROW EXECUTE FUNCTION increment_resource_version(); +CREATE TRIGGER ticket_version BEFORE UPDATE ON execution_tickets FOR EACH ROW EXECUTE FUNCTION increment_resource_version(); + +CREATE FUNCTION check_isolated_task() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE wid uuid; +BEGIN + IF TG_TABLE_NAME='workspaces' THEN wid:=COALESCE(NEW.id,OLD.id); + ELSE + IF TG_OP='UPDATE' AND NEW.workspace_id<>OLD.workspace_id THEN + RAISE EXCEPTION 'task workspace is immutable' USING ERRCODE='23514'; + END IF; + wid:=COALESCE(NEW.workspace_id,OLD.workspace_id); + END IF; + IF EXISTS(SELECT 1 FROM workspaces WHERE id=wid AND kind='isolated' AND deleted_at IS NULL) AND + (SELECT count(*) FROM tasks WHERE workspace_id=wid AND deleted_at IS NULL)<>1 THEN + RAISE EXCEPTION 'live isolated workspace requires one task identity' USING ERRCODE='23514'; END IF; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER isolated_task AFTER INSERT OR UPDATE OR DELETE ON workspaces DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_isolated_task(); +CREATE CONSTRAINT TRIGGER task_identity AFTER INSERT OR UPDATE OR DELETE ON tasks DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_isolated_task(); diff --git a/internal/core/migrations/0004_effect_intent_and_ticket_scope.sql b/internal/core/migrations/0004_effect_intent_and_ticket_scope.sql new file mode 100644 index 0000000..92dad65 --- /dev/null +++ b/internal/core/migrations/0004_effect_intent_and_ticket_scope.sql @@ -0,0 +1,31 @@ +ALTER TABLE external_effects ADD COLUMN request jsonb; + +UPDATE external_effects e +SET request=jsonb_strip_nulls(jsonb_build_object( + 'kind',e.kind, + 'projectId',e.project_id::text, + 'workspaceId',e.workspace_id::text, + 'repositoryUrl',CASE WHEN e.kind='worktree_ensure' THEN p.repository_url END, + 'requestedRef',CASE WHEN e.kind='worktree_ensure' THEN ( + SELECT wt.requested_ref FROM workspace_worktrees wt WHERE wt.workspace_id=e.workspace_id + ) END, + 'sandboxInstanceId',CASE WHEN e.kind='sandbox_terminate' THEN ( + SELECT s.id::text FROM sandbox_instances s + WHERE s.workspace_id=e.workspace_id + ORDER BY s.generation DESC LIMIT 1 + ) END +)) +FROM projects p +WHERE p.id=e.project_id; + +ALTER TABLE external_effects ALTER COLUMN request SET NOT NULL; +ALTER TABLE external_effects ADD CHECK(jsonb_typeof(request)='object'); + +ALTER TABLE execution_tickets ADD COLUMN tenant_id uuid; +UPDATE execution_tickets ticket +SET tenant_id=workspace.tenant_id +FROM workspaces workspace +WHERE workspace.id=ticket.workspace_id; +ALTER TABLE execution_tickets ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE execution_tickets ADD FOREIGN KEY(workspace_id,tenant_id,actor_user_id) + REFERENCES workspaces(id,tenant_id,owner_user_id); diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md new file mode 100644 index 0000000..08bc3b0 --- /dev/null +++ b/internal/core/migrations/README.md @@ -0,0 +1,35 @@ +# Database Migrations Module + +This module contains Ora Cloud's linear, forward-only PostgreSQL schema migration catalog. Migrations are embedded directly into the Go application binary using `embed.FS` and applied deterministically by `cloudctl migrate`. + +## Migration catalog + +Migrations are executed in ascending numerical sequence: + +- **`0001_core.sql`**: Foundational domain schema: + - Identity & Access: `users`, `user_identities`, `tenants`, `tenant_memberships`, `credential_refs`. + - Projects & Workspaces: `projects`, `project_storage`, `workspaces`, `workspace_worktrees`, `tasks`. + - Execution runtime: `sandbox_instances`, `workspace_nodes`, `sessions`. + - Control plane: `effects`, `operations`, `tickets`, `controller_leases`, `idempotency_keys`. + - Invariants: Partial unique index `one_main` ensures at most one active `main` workspace per project. Foreign keys strictly enforce tenant and owner containment across all hierarchy tiers. +- **`0002_aggregate_guards.sql`**: Concurrency and mutual exclusion guards: + - Prevents concurrent lifecycle mutations on the same project aggregate. + - Ensures soft-deleted ancestors prevent active child state transitions. +- **`0003_resource_versions.sql`**: Optimistic concurrency controls: + - Enforces `version` incrementing rules across mutable entities (`projects`, `workspaces`, `tasks`, `nodes`, `operations`). + - Guards against lost updates in concurrent API operations. +- **`0004_effect_intent_and_ticket_scope.sql`**: Execution intent and ticket constraints: + - Enforces strict scoping of execution tickets to active workspace nodes and valid admission epochs. + - Binds durable effect declarations to specific operation phases. + +## Checksum integrity and immutability + +- **`schema_migrations` table**: Tracks applied versions, their SHA256 checksums, and application timestamps (`version`, `checksum`, `applied_at`). +- **Server startup check**: At startup, `cmd/server` runs `store.CheckSchema`, verifying that: + 1. All embedded `.sql` migration files exist in `schema_migrations`. + 2. The SHA256 checksum of each embedded file matches the recorded checksum in the database. + 3. No unknown or extraneous migration versions exist in the database. + If any mismatch or unapplied migration is found, the server terminates immediately. +- **No AutoMigrate**: The production server daemon **never** executes DDL or modifies table structures at startup. Migrations must be applied using `cloudctl migrate` under dedicated database administrator credentials. + +See [core overview](../README.md), [cloudctl CLI](../../../cmd/cloudctl/README.md), and [Core contract](../../../docs/core-contract.md). diff --git a/internal/core/node.go b/internal/core/node.go new file mode 100644 index 0000000..6e46948 --- /dev/null +++ b/internal/core/node.go @@ -0,0 +1,103 @@ +package core + +func access(t *transaction, r *ControlRequest) Object { + require(r.Identity != nil, 401, "user_credential_required") + u := identity(t, r.Identity.Source, r.Identity.Subject, r.Identity.DisplayName) + tid, wid := r.Body.S("tenantId"), r.Body.S("workspaceId") + membership(t, tid, u.S("id"), false) + w := workspace(t, tid, u.S("id"), wid, false) + p := project(t, tid, u.S("id"), w.S("projectId")) + action := r.Body.S("action") + require(action == "read" || action == "execute", 400, "invalid_action") + executable := w.B("admissionOpen") && w.S("desiredState") == "running" && w.S("observedState") == "ready" && p.S("lifecycle") == "active" + n := t.one("SELECT n.id,s.id AS sandbox_id,s.generation FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id WHERE s.workspace_id=$1 AND s.generation=$2 AND s.terminated_at IS NULL AND n.ended_at IS NULL AND n.initialized AND n.connection_state='connected' AND n.last_seen_at>clock_timestamp()-interval '30 seconds'", wid, w.N("runtimeGeneration")) + executable = executable && n != nil + if action == "execute" { + require(executable, 409, "execution_closed") + } + if r.Action == "access" { + return Object{"userId": u.S("id"), "tenantId": tid, "workspaceId": wid, "allowedAction": action, "executable": executable, "runtimeGeneration": w.N("runtimeGeneration")} + } + require(action == "execute", 400, "invalid_action") + ticketID := r.Body.S("ticketId") + kind := r.Body.S("kind") + require(validID(ticketID) && (kind == "task" || kind == "interaction"), 400, "invalid_ticket") + existing := t.one("SELECT * FROM execution_tickets WHERE id=$1", ticketID) + if existing != nil { + require(existing.S("tenantId") == tid && existing.S("workspaceId") == wid && existing.S("actorUserId") == u.S("id") && existing.S("kind") == kind, 409, "idempotency_conflict") + return existing + } + t.exec("INSERT INTO execution_tickets(id,tenant_id,workspace_id,node_instance_id,actor_user_id,admission_epoch,kind,state) VALUES($1,$2,$3,$4,$5,$6,$7,'active')", ticketID, tid, wid, n.S("id"), u.S("id"), w.N("admissionEpoch"), kind) + return t.one("SELECT * FROM execution_tickets WHERE id=$1", ticketID) +} + +func nodeCommand(t *transaction, r *ControlRequest) Object { + c := r.Service + require(validID(c.Subject) && validID(c.WorkspaceID) && validID(c.SandboxID) && c.Generation > 0, 403, "node_scope_required") + w := t.one("SELECT * FROM workspaces WHERE id=$1 AND runtime_generation=$2 AND deleted_at IS NULL", c.WorkspaceID, c.Generation) + require(w != nil, 409, "stale_node") + sandbox := t.one("SELECT * FROM sandbox_instances WHERE id=$1 AND workspace_id=$2 AND generation=$3 AND terminated_at IS NULL AND substrate_sandbox_id IS NOT NULL", c.SandboxID, c.WorkspaceID, c.Generation) + require(sandbox != nil && sandbox.S("observedState") != "terminating", 409, "stale_sandbox") + if r.Action == "node_register" { + require(r.Body.N("protocolVersion") == 1, 400, "unsupported_protocol") + require(w.S("desiredState") == "running", 409, "execution_closed") + existing := t.one("SELECT * FROM node_instances WHERE id=$1", c.Subject) + if existing != nil { + require(existing.S("sandboxInstanceId") == c.SandboxID && existing["endedAt"] == nil, 409, "stale_node") + return existing + } + require(t.one("SELECT id FROM node_instances WHERE sandbox_instance_id=$1 AND ended_at IS NULL", c.SandboxID) == nil, 409, "node_already_registered") + t.exec("INSERT INTO node_instances(id,sandbox_instance_id,workspace_id,service_subject,connection_state,protocol_version) VALUES($1,$2,$3,$4,'connected',1)", c.Subject, c.SandboxID, c.WorkspaceID, c.Subject) + return t.one("SELECT * FROM node_instances WHERE id=$1", c.Subject) + } + n := t.one("SELECT * FROM node_instances WHERE id=$1 AND sandbox_instance_id=$2 AND ended_at IS NULL AND service_subject=$3", c.Subject, c.SandboxID, c.Subject) + require(n != nil, 409, "stale_node") + switch r.Action { + case "node_status": + version(n, r.Body.N("version")) + state := r.Body.S("connectionState") + require(state == "connected" || state == "disconnected", 400, "invalid_node_state") + require(!n.B("initialized") || r.Body.B("initialized"), 409, "initialization_regression") + t.exec("UPDATE node_instances SET connection_state=$2,initialized=$3,last_seen_at=clock_timestamp(),idle_admission_epoch=NULL,version=version+1 WHERE id=$1", c.Subject, state, r.Body.B("initialized")) + if state == "disconnected" && w.S("observedState") == "ready" { + t.exec("UPDATE workspaces SET admission_open=false,observed_state='unavailable',version=version+1 WHERE id=$1", w.S("id")) + } + case "node_finish": + require(validID(r.TicketID), 404, "not_found") + ticket := t.one("SELECT * FROM execution_tickets WHERE id=$1 AND node_instance_id=$2 AND workspace_id=$3", r.TicketID, c.Subject, c.WorkspaceID) + require(ticket != nil, 404, "not_found") + if ticket.S("state") == "finished" { + return ticket + } + version(ticket, r.Body.N("version")) + t.exec("UPDATE execution_tickets SET state='finished',finished_at=COALESCE(finished_at,now()) WHERE id=$1", r.TicketID) + return t.one("SELECT * FROM execution_tickets WHERE id=$1", r.TicketID) + case "node_idle": + version(n, r.Body.N("version")) + require(!w.B("admissionOpen") && r.Body.N("admissionEpoch") == w.N("admissionEpoch"), 409, "stale_admission") + require(validID(r.Body.S("operationId")), 400, "operation_required") + o := t.one("SELECT * FROM operations WHERE id=$1 AND project_id=$2 AND (workspace_id IS NULL OR workspace_id=$3) AND step='quiesce' AND state IN ('queued','running','retry_wait','blocked')", r.Body.S("operationId"), w.S("projectId"), w.S("id")) + require(o != nil, 409, "idle_not_requested") + if !r.Body.B("idle") { + restoreAdmission(t, o) + return Object{"accepted": false, "errorCode": "resource_in_use"} + } + checkActivities(t, w) + require(n.B("initialized") && n.S("connectionState") == "connected", 409, "idle_unconfirmed") + t.exec("UPDATE node_instances SET idle_admission_epoch=$2,last_seen_at=clock_timestamp(),version=version+1 WHERE id=$1", c.Subject, w.N("admissionEpoch")) + default: + reject(404, "not_found") + } + return t.one("SELECT * FROM node_instances WHERE id=$1", c.Subject) +} + +func restoreAdmission(t *transaction, o Object) { + for wid, v := range o.O("request").O("previous") { + raw, ok := v.(map[string]any) + require(ok, 500, "internal_error") + w := Object(raw) + t.exec("UPDATE workspaces SET desired_state=$2,observed_state=$3,admission_open=$4,admission_epoch=admission_epoch+1,version=version+1 WHERE id=$1", wid, w.S("desiredState"), w.S("observedState"), w.B("admissionOpen")) + } + t.exec("UPDATE projects SET lifecycle='active',version=version+1 WHERE id=$1 AND lifecycle='deleting'", o.S("projectId")) + t.exec("UPDATE operations SET state='failed',error_code='resource_in_use',version=version+1,updated_at=now() WHERE id=$1", o.S("id")) +} diff --git a/internal/core/public.go b/internal/core/public.go new file mode 100644 index 0000000..004ad8f --- /dev/null +++ b/internal/core/public.go @@ -0,0 +1,314 @@ +package core + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +func itoa(n int) string { return strconv.Itoa(n) } + +// PublicRequest is populated only after service and final-user credentials are verified. +type PublicRequest struct { + Method, Path, TenantID, ProjectID, WorkspaceID, OperationID, UserID, Key, After string + Limit int + Body Object + Identity *Claims +} + +// Public executes one authorized public request in a short database transaction. +func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, error) { + status := 200 + result, e := s.transact(ctx, func(t *transaction) Object { + u := identity(t, r.Identity.Source, r.Identity.Subject, r.Identity.DisplayName) + uid := u.S("id") + if r.Path == "/api/v1/me" { + return u + } + if r.Path == "/api/v1/me/tenants" { + return page(t, "SELECT t.id,t.name,t.status,m.role FROM tenants t JOIN tenant_memberships m ON m.tenant_id=t.id WHERE m.user_id=$1 AND m.status='active' AND t.status='active' AND t.deleted_at IS NULL", []any{uid}, "t.id", r) + } + isAdmin := strings.HasSuffix(r.Path, "/resource-status") || strings.HasSuffix(r.Path, "/administrative-stop") || (r.UserID != "" && r.Method == "PUT") || strings.HasSuffix(r.Path, "/members") + membership(t, r.TenantID, uid, isAdmin) + if r.Method == "GET" { + return readPublic(t, r, uid) + } + hash := requestHash(r.Method, r.Path, r.Body) + idempotent := r.Method == "POST" || r.Method == "DELETE" + if idempotent { + require(r.Key != "" && len(r.Key) <= 200, 400, "idempotency_key_required") + old := t.one("SELECT * FROM idempotency_records WHERE tenant_id=$1 AND user_id=$2 AND key=$3", r.TenantID, uid, r.Key) + if old != nil { + require(old.S("requestHash") == hash, 409, "idempotency_conflict") + status = int(old.N("status")) + return old.O("response") + } + } + var out Object + switch { + case r.UserID != "" && r.Method == "PUT": + out = putMember(t, r, uid) + case r.ProjectID == "" && r.WorkspaceID == "" && r.OperationID == "" && strings.HasSuffix(r.Path, "/projects") && r.Method == "POST": + out = createProject(t, r, uid, hash) + status = 202 + case r.OperationID != "": + out = retryOperation(t, r, uid) + status = 202 + case r.WorkspaceID != "": + out = workspaceAction(t, r, uid, hash, isAdmin) + status = 202 + case r.ProjectID != "": + p := project(t, r.TenantID, uid, r.ProjectID) + switch { + case strings.HasSuffix(r.Path, "/workspaces"): + out = createWorkspace(t, r, p, uid, hash) + status = 202 + case r.Method == "PATCH": + version(p, r.Body.N("version")) + name := validText(r.Body.S("name"), 200) + require(p.S("lifecycle") != "deleting", 409, "resource_unavailable") + t.exec("UPDATE projects SET name=$2,version=version+1 WHERE id=$1", p.S("id"), name) + out = project(t, r.TenantID, uid, p.S("id")) + default: + require(r.Method == "DELETE", 405, "method_not_allowed") + version(p, r.Body.N("version")) + idleProject(t, p.S("id")) + require(p.S("lifecycle") == "active", 409, "resource_unavailable") + ws := t.list("SELECT * FROM workspaces WHERE project_id=$1 AND deleted_at IS NULL ORDER BY id", p.S("id")) + previous := Object{} + for _, w := range ws { + checkActivities(t, w) + previous[w.S("id")] = w + } + for _, w := range ws { + closeAdmission(t, w, "deleted") + } + t.exec("UPDATE projects SET lifecycle='deleting',version=version+1 WHERE id=$1", p.S("id")) + req := Object{"previous": previous} + op := newOperation(t, r, uid, p.S("id"), "", "delete_project", "quiesce", hash, req) + out = Object{"resource": project(t, r.TenantID, uid, p.S("id")), "operation": op} + status = 202 + } + default: + reject(404, "not_found") + } + if idempotent { + t.exec("INSERT INTO idempotency_records(tenant_id,user_id,key,request_hash,response,status) VALUES($1,$2,$3,$4,$5,$6)", r.TenantID, uid, r.Key, hash, jsonText(out), status) + } + return out + }) + return result, status, e +} + +func page(t *transaction, q string, args []any, col string, r *PublicRequest) Object { + limit := r.Limit + if limit == 0 { + limit = 50 + } + require(limit > 0 && limit <= 100, 400, "invalid_pagination") + if r.After != "" { + require(validID(r.After), 400, "invalid_cursor") + args = append(args, r.After) + q += " AND " + col + " > $" + itoa(len(args)) + "::uuid" + } + args = append(args, limit+1) + q += " ORDER BY " + col + " LIMIT $" + itoa(len(args)) + items := t.list(q, args...) + next := "" + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].S("id") + } + return Object{"items": items, "nextCursor": next} +} + +func readPublic(t *transaction, r *PublicRequest, uid string) Object { + switch { + case strings.HasSuffix(r.Path, "/members"): + return page(t, "SELECT m.user_id AS id,m.tenant_id,m.user_id,m.role,m.status,m.version,u.display_name FROM tenant_memberships m JOIN users u ON u.id=m.user_id WHERE m.tenant_id=$1", []any{r.TenantID}, "m.user_id", r) + case strings.HasSuffix(r.Path, "/resource-status"): + return page(t, "SELECT w.id,w.project_id,w.owner_user_id,w.kind,w.desired_state,w.observed_state,w.runtime_generation,w.version FROM workspaces w WHERE w.tenant_id=$1 AND w.deleted_at IS NULL", []any{r.TenantID}, "w.id", r) + case r.OperationID != "": + return ownedOperation(t, r, uid) + case r.WorkspaceID != "": + return workspace(t, r.TenantID, uid, r.WorkspaceID, false) + case r.ProjectID != "": + p := project(t, r.TenantID, uid, r.ProjectID) + if strings.HasSuffix(r.Path, "/workspaces") { + return page(t, "SELECT w.*,wt.branch_name,wt.base_commit_id,task.title FROM workspaces w JOIN workspace_worktrees wt ON wt.workspace_id=w.id LEFT JOIN tasks task ON task.workspace_id=w.id WHERE w.project_id=$1 AND w.tenant_id=$2 AND w.owner_user_id=$3 AND w.deleted_at IS NULL", []any{p.S("id"), r.TenantID, uid}, "w.id", r) + } + return p + default: + return page(t, "SELECT * FROM projects WHERE tenant_id=$1 AND owner_user_id=$2 AND deleted_at IS NULL", []any{r.TenantID, uid}, "id", r) + } +} + +func putMember(t *transaction, r *PublicRequest, uid string) Object { + require(validID(r.UserID), 400, "invalid_user") + require(t.one("SELECT id FROM users WHERE id=$1 AND status='active' AND deleted_at IS NULL", r.UserID) != nil, 404, "not_found") + role, status := r.Body.S("role"), r.Body.S("status") + require((role == "admin" || role == "member") && (status == "active" || status == "disabled"), 400, "invalid_member") + old := t.one("SELECT * FROM tenant_memberships WHERE tenant_id=$1 AND user_id=$2", r.TenantID, r.UserID) + if old != nil { + version(old, r.Body.N("version")) + if old.S("role") == "admin" && old.S("status") == "active" && (role != "admin" || status != "active") { + require(t.one("SELECT m.user_id FROM tenant_memberships m JOIN users u ON u.id=m.user_id WHERE m.tenant_id=$1 AND m.user_id<>$2 AND m.role='admin' AND m.status='active' AND u.status='active' AND u.deleted_at IS NULL", r.TenantID, r.UserID) != nil, 409, "last_admin") + } + t.exec("UPDATE tenant_memberships SET role=$3,status=$4,version=version+1 WHERE tenant_id=$1 AND user_id=$2", r.TenantID, r.UserID, role, status) + } else { + require(r.Body.N("version") == 0, 409, "version_conflict") + t.exec("INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,$3,$4)", r.TenantID, r.UserID, role, status) + } + return t.one("SELECT * FROM tenant_memberships WHERE tenant_id=$1 AND user_id=$2", r.TenantID, r.UserID) +} + +func validText(s string, maximum int) string { + s = strings.TrimSpace(s) + require(s != "" && len(s) <= maximum, 400, "invalid_input") + return s +} + +func validRef(s string) string { + s = validText(s, 200) + require(!strings.HasPrefix(s, "-") && !strings.ContainsAny(s, "\x00\r\n ~^:?*[\\") && !strings.Contains(s, "..") && !strings.Contains(s, "@{"), 400, "invalid_ref") + return s +} + +func createProject(t *transaction, r *PublicRequest, uid, hash string) Object { + name := validText(r.Body.S("name"), 200) + repo := validText(r.Body.S("repositoryUrl"), 2048) + parsed, e := url.Parse(repo) + require(e == nil && (parsed.Scheme == "https" || parsed.Scheme == "ssh") && parsed.Host != "" && parsed.RawQuery == "" && parsed.Fragment == "", 400, "invalid_repository_url") + if parsed.User != nil { + _, password := parsed.User.Password() + require(!password && parsed.Scheme == "ssh", 400, "embedded_credentials_forbidden") + } + branch := r.Body.S("defaultBranch") + if branch == "" { + branch = "HEAD" + } + branch = validRef(branch) + var cred any + if id := r.Body.S("credentialRefId"); id != "" { + require(validID(id), 400, "invalid_credential_ref") + require(t.one("SELECT id FROM credential_refs WHERE id=$1 AND tenant_id=$2 AND owner_user_id=$3 AND purpose='git' AND deleted_at IS NULL", id, r.TenantID, uid) != nil, 404, "credential_ref_not_found") + cred = id + } + pid, wid := newID(), newID() + t.exec("INSERT INTO projects(id,tenant_id,owner_user_id,name,repository_url,default_branch,credential_ref_id,lifecycle) VALUES($1,$2,$3,$4,$5,$6,$7,'provisioning')", pid, r.TenantID, uid, name, repo, branch, cred) + t.exec("INSERT INTO project_storage(project_id,observed_state) VALUES($1,'pending')", pid) + insertWorkspace(t, r.TenantID, uid, pid, wid, "main", branch, "") + op := newOperation(t, r, uid, pid, wid, "create_project", "storage", hash, Object{}) + return Object{"resource": project(t, r.TenantID, uid, pid), "workspace": workspace(t, r.TenantID, uid, wid, false), "operation": op} +} + +func insertWorkspace(t *transaction, tid, uid, pid, wid, kind, ref, title string) { + t.exec("INSERT INTO workspaces(id,tenant_id,owner_user_id,project_id,kind,desired_state,observed_state) VALUES($1,$2,$3,$4,$5,'running','provisioning')", wid, tid, uid, pid, kind) + t.exec("INSERT INTO workspace_worktrees(workspace_id,relative_path,branch_name,requested_ref,provisioning_state) VALUES($1,$2,$3,$4,'pending')", wid, "workspaces/"+wid+"/checkout", "ora/"+wid, ref) + if kind == "isolated" { + t.exec("INSERT INTO tasks(id,workspace_id,title) VALUES($1,$2,$3)", newID(), wid, title) + } +} + +func createWorkspace(t *transaction, r *PublicRequest, p Object, uid, hash string) Object { + require(p.S("lifecycle") == "active", 409, "resource_unavailable") + idleProject(t, p.S("id")) + title := validText(r.Body.S("title"), 200) + ref := validRef(r.Body.S("baseRef")) + wid := newID() + insertWorkspace(t, r.TenantID, uid, p.S("id"), wid, "isolated", ref, title) + op := newOperation(t, r, uid, p.S("id"), wid, "create_workspace", "worktree", hash, Object{}) + return Object{"resource": workspace(t, r.TenantID, uid, wid, false), "operation": op} +} + +func newOperation(t *transaction, r *PublicRequest, uid, pid, wid, kind, step, hash string, req Object) Object { + id := newID() + var w any + if wid != "" { + w = wid + } + t.exec("INSERT INTO operations(id,tenant_id,actor_user_id,project_id,workspace_id,kind,state,step,request,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,$6,'queued',$7,$8,$9,$10)", id, r.TenantID, uid, pid, w, kind, step, jsonText(req), r.Key, hash) + return t.one("SELECT * FROM operations WHERE id=$1", id) +} + +func checkActivities(t *transaction, w Object) { + require(t.one("SELECT id FROM execution_tickets WHERE workspace_id=$1 AND state='active'", w.S("id")) == nil, 409, "resource_in_use") +} + +func closeAdmission(t *transaction, w Object, desired string) { + state := "stopping" + if desired == "deleted" { + state = "deleting" + } + t.exec("UPDATE workspaces SET admission_open=false,admission_epoch=admission_epoch+1,desired_state=$2,observed_state=$3,version=version+1 WHERE id=$1", w.S("id"), desired, state) +} + +func adminResource(w Object) Object { + o := Object{} + for _, k := range []string{"id", "projectId", "ownerUserId", "kind", "desiredState", "observedState", "runtimeGeneration", "version"} { + o[k] = w[k] + } + return o +} + +func adminOperation(o Object) Object { + out := Object{} + for _, k := range []string{"id", "tenantId", "projectId", "workspaceId", "kind", "state", "step", "version", "createdAt", "updatedAt"} { + out[k] = o[k] + } + return out +} + +func workspaceAction(t *transaction, r *PublicRequest, uid, hash string, admin bool) Object { + w := workspace(t, r.TenantID, uid, r.WorkspaceID, admin) + p := t.one("SELECT * FROM projects WHERE id=$1", w.S("projectId")) + require(p.S("lifecycle") == "active", 409, "resource_unavailable") + version(w, r.Body.N("version")) + idleProject(t, p.S("id")) + kind, step := "stop", "quiesce" + req := Object{"previous": Object{w.S("id"): w}} + if strings.HasSuffix(r.Path, "/start") { + kind, step = "start", "sandbox" + require(w.S("desiredState") == "stopped" && w.S("observedState") == "stopped", 409, "resource_unavailable") + t.exec("UPDATE workspaces SET desired_state='running',observed_state='starting',version=version+1 WHERE id=$1", w.S("id")) + } else { + checkActivities(t, w) + require(w.S("observedState") == "ready" || w.S("observedState") == "stopped" || w.S("observedState") == "unavailable", 409, "resource_unavailable") + desired := "stopped" + if r.Method == "DELETE" { + require(w.S("kind") == "isolated", 409, "main_workspace_required") + kind, desired = "delete_workspace", "deleted" + } + if admin { + kind = "administrative_stop" + } + closeAdmission(t, w, desired) + } + op := newOperation(t, r, uid, p.S("id"), w.S("id"), kind, step, hash, req) + resource := workspace(t, r.TenantID, uid, w.S("id"), admin) + if admin { + resource = adminResource(resource) + op = adminOperation(op) + } + return Object{"resource": resource, "operation": op} +} + +func ownedOperation(t *transaction, r *PublicRequest, uid string) Object { + require(validID(r.OperationID), 404, "not_found") + o := t.one("SELECT o.* FROM operations o JOIN projects p ON p.id=o.project_id WHERE o.id=$1 AND o.tenant_id=$2 AND (p.owner_user_id=$3 OR (o.kind='administrative_stop' AND o.actor_user_id=$3))", r.OperationID, r.TenantID, uid) + require(o != nil, 404, "not_found") + if o.S("kind") == "administrative_stop" { + membership(t, r.TenantID, uid, true) + return adminOperation(o) + } + return o +} + +func retryOperation(t *transaction, r *PublicRequest, uid string) Object { + o := ownedOperation(t, r, uid) + version(o, r.Body.N("version")) + require(o.S("state") == "blocked" || o.S("state") == "retry_wait", 409, "operation_not_retryable") + t.exec("UPDATE operations SET state='queued',retry_at=NULL,error_code=NULL,version=version+1,updated_at=now() WHERE id=$1", r.OperationID) + return Object{"operation": ownedOperation(t, r, uid)} +} diff --git a/internal/core/store.go b/internal/core/store.go new file mode 100644 index 0000000..181c377 --- /dev/null +++ b/internal/core/store.go @@ -0,0 +1,352 @@ +// Package core implements the authoritative cloud aggregate and control contracts. +package core + +import ( + "context" + "crypto/sha256" + "database/sql" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Object is a JSON resource; database column names are converted at the persistence boundary. +type Object map[string]any + +// S returns the string field at key, or the empty string when the field is absent or not a string. +func (o Object) S(k string) string { v, _ := o[k].(string); return v } + +// N returns the integer field at key, or zero when the field is absent or not an exact integer. +func (o Object) N(k string) int64 { + switch v := o[k].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + } + return 0 +} + +// B returns the boolean field at key, or false when the field is absent or not a boolean. +func (o Object) B(k string) bool { v, _ := o[k].(bool); return v } + +// O returns the object field at key, or an empty object when the field is absent or not an object. +func (o Object) O(k string) Object { + switch v := o[k].(type) { + case map[string]any: + return Object(v) + case Object: + return v + } + return Object{} +} + +// Fault is the stable error contract. Internal database detail never reaches clients. +type Fault struct { + Code string `json:"code"` + Params Object `json:"params"` + Status int `json:"-"` +} + +func (e *Fault) Error() string { return e.Code } +func reject(status int, code string) { panic(&Fault{Code: code, Status: status, Params: Object{}}) } +func require(ok bool, status int, code string) { + if !ok { + reject(status, code) + } +} + +type databaseFailure struct{ err error } + +// Store is injected; there is no global database handle. +type Store struct{ Pool *sql.DB } + +// NewStore obtains the injected SQL pool without creating or migrating schema. +func NewStore(db *gorm.DB) (*Store, error) { + if db == nil { + return nil, fmt.Errorf("database is required") + } + pool, err := db.DB() + if err != nil { + return nil, fmt.Errorf("get database pool: %w", err) + } + return &Store{Pool: pool}, nil +} + +type transaction struct { + tx *sql.Tx + ctx context.Context +} + +func (t *transaction) exec(q string, args ...any) { + if _, e := t.tx.ExecContext(t.ctx, q, args...); e != nil { + panic(databaseFailure{e}) + } +} + +func (t *transaction) list(q string, args ...any) []Object { + // q is assembled only from package-owned SQL fragments; all external values are bound. + rows, e := t.tx.QueryContext(t.ctx, "SELECT row_to_json(resource) FROM ("+q+") resource", args...) // #nosec G202 -- fixed SQL fragments, parameterized values. + if e != nil { + panic(databaseFailure{e}) + } + defer rows.Close() + out := []Object{} + for rows.Next() { + var b []byte + if e = rows.Scan(&b); e != nil { + panic(databaseFailure{e}) + } + var raw Object + if e = json.Unmarshal(b, &raw); e != nil { + panic(databaseFailure{e}) + } + o := Object{} + for k, v := range raw { + o[camel(k)] = v + } + out = append(out, o) + } + if e = rows.Err(); e != nil { + panic(databaseFailure{e}) + } + return out +} + +func (t *transaction) one(q string, args ...any) Object { + a := t.list(q, args...) + if len(a) == 0 { + return nil + } + return a[0] +} + +func camel(s string) string { + p := strings.Split(s, "_") + for i := 1; i < len(p); i++ { + if p[i] != "" { + p[i] = strings.ToUpper(p[i][:1]) + p[i][1:] + } + } + return strings.Join(p, "") +} + +func jsonText(v any) string { + b, e := json.Marshal(v) + if e != nil { + panic(databaseFailure{e}) + } + return string(b) +} +func newID() string { return uuid.NewString() } +func validID(s string) bool { _, e := uuid.Parse(s); return e == nil } + +// transact serializes mutations within the single-cluster phase-one control plane. +// The lock is transaction scoped, never spans HTTP or external work. It deliberately +// trades write throughput for a simple, auditable lock order; reads use the same boundary. +func (s *Store) transact(ctx context.Context, fn func(*transaction) Object) (out Object, err error) { + tx, e := s.Pool.BeginTx(ctx, nil) + if e != nil { + return nil, e + } + defer func() { _ = tx.Rollback() }() + defer func() { + if r := recover(); r != nil { + switch v := r.(type) { + case *Fault: + err = v + case databaseFailure: + err = v.err + default: + panic(r) + } + } + }() + t := &transaction{tx: tx, ctx: ctx} + t.exec("SELECT pg_advisory_xact_lock(67420911)") + out = fn(t) + err = tx.Commit() + return out, err +} + +//go:embed migrations/*.sql +var migrations embed.FS + +// CheckSchema rejects a missing or changed migration without mutating production schema. +func (s *Store) CheckSchema(ctx context.Context) error { + actual, err := schemaMigrations(ctx, s.Pool) + if err != nil { + return err + } + + entries, e := migrations.ReadDir("migrations") + if e != nil { + return fmt.Errorf("read embedded migrations: %w", e) + } + for _, entry := range entries { + b, e := migrations.ReadFile("migrations/" + entry.Name()) + if e != nil { + return fmt.Errorf("read embedded migration %s: %w", entry.Name(), e) + } + sum := sha256.Sum256(b) + checksum, ok := actual[entry.Name()] + if !ok { + return fmt.Errorf("migration %s is missing; run cloudctl migrate", entry.Name()) + } + if checksum != hex.EncodeToString(sum[:]) { + return fmt.Errorf("migration checksum mismatch: %s", entry.Name()) + } + delete(actual, entry.Name()) + } + for version := range actual { + return fmt.Errorf("database contains migration unknown to this binary: %s", version) + } + return nil +} + +func schemaMigrations(ctx context.Context, pool *sql.DB) (actual map[string]string, err error) { + rows, err := pool.QueryContext(ctx, "SELECT version,checksum FROM schema_migrations ORDER BY version") + if err != nil { + return nil, fmt.Errorf("read schema migrations; run cloudctl migrate: %w", err) + } + defer func() { err = errors.Join(err, rows.Close()) }() + actual = map[string]string{} + for rows.Next() { + var version, checksum string + if err = rows.Scan(&version, &checksum); err != nil { + return nil, fmt.Errorf("read schema migration: %w", err) + } + actual[version] = checksum + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("read schema migrations: %w", err) + } + return actual, nil +} + +// Migrate applies explicit ordered SQL migrations under a database advisory lock. +func (s *Store) Migrate(ctx context.Context) error { + _, e := s.transact(ctx, func(t *transaction) Object { + t.exec("CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())") + entries, err := migrations.ReadDir("migrations") + if err != nil { + panic(databaseFailure{err}) + } + for _, entry := range entries { + b, err := migrations.ReadFile("migrations/" + entry.Name()) + if err != nil { + panic(databaseFailure{err}) + } + sum := sha256.Sum256(b) + hash := hex.EncodeToString(sum[:]) + old := t.one("SELECT checksum FROM schema_migrations WHERE version=$1", entry.Name()) + if old != nil { + require(old.S("checksum") == hash, 409, "migration_checksum_mismatch") + continue + } + t.exec(string(b)) + t.exec("INSERT INTO schema_migrations(version,checksum) VALUES($1,$2)", entry.Name(), hash) + } + return Object{"migrated": true} + }) + return e +} + +func identity(t *transaction, source, subject, name string) Object { + require(source != "" && len(source) <= 128 && subject != "" && len(subject) <= 512 && len(name) <= 200, 401, "invalid_identity") + u := t.one("SELECT u.* FROM users u JOIN user_identities i ON i.user_id=u.id WHERE i.source=$1 AND i.subject=$2", source, subject) + if u == nil { + id := newID() + t.exec("INSERT INTO users(id,display_name,status) VALUES($1,$2,'active')", id, name) + t.exec("INSERT INTO user_identities(user_id,source,subject) VALUES($1,$2,$3)", id, source, subject) + u = t.one("SELECT * FROM users WHERE id=$1", id) + } + require(u.S("status") == "active" && u["deletedAt"] == nil, 403, "user_disabled") + return u +} + +// Bootstrap atomically provisions a tenant and its initial administrator from a deployment command. +func (s *Store) Bootstrap(ctx context.Context, name, source, subject, display string) (Object, error) { + return s.transact(ctx, func(t *transaction) Object { + require(name != "" && len(name) <= 200, 400, "invalid_name") + u := identity(t, source, subject, display) + id := newID() + t.exec("INSERT INTO tenants(id,name,status) VALUES($1,$2,'active')", id, name) + t.exec("INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')", id, u.S("id")) + return Object{"tenantId": id, "userId": u.S("id")} + }) +} + +// ConfigureCredential is deliberately a deployment-only management path, never a public secret API. +func (s *Store) ConfigureCredential(ctx context.Context, tid, owner, ref string) (Object, error) { + return s.transact(ctx, func(t *transaction) Object { + membership(t, tid, owner, false) + require(strings.TrimSpace(ref) != "" && len(ref) <= 1024, 400, "invalid_secret_ref") + id := newID() + t.exec("INSERT INTO credential_refs(id,tenant_id,owner_user_id,purpose,secret_ref) VALUES($1,$2,$3,'git',$4)", id, tid, owner, ref) + return Object{"id": id, "tenantId": tid, "ownerUserId": owner, "purpose": "git"} + }) +} + +func membership(t *transaction, tid, uid string, admin bool) Object { + require(validID(tid) && validID(uid), 404, "not_found") + m := t.one("SELECT m.* FROM tenant_memberships m JOIN tenants t ON t.id=m.tenant_id JOIN users u ON u.id=m.user_id WHERE m.tenant_id=$1 AND m.user_id=$2 AND m.status='active' AND t.status='active' AND t.deleted_at IS NULL AND u.status='active' AND u.deleted_at IS NULL", tid, uid) + require(m != nil, 403, "membership_required") + require(!admin || m.S("role") == "admin", 403, "admin_required") + return m +} + +func project(t *transaction, tid, uid, pid string) Object { + require(validID(pid), 404, "not_found") + p := t.one("SELECT * FROM projects WHERE id=$1 AND tenant_id=$2 AND owner_user_id=$3 AND deleted_at IS NULL", pid, tid, uid) + require(p != nil, 404, "not_found") + return p +} + +func workspace(t *transaction, tid, uid, wid string, admin bool) Object { + require(validID(wid), 404, "not_found") + q := "SELECT * FROM workspaces WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL" + args := []any{wid, tid} + if !admin { + q += " AND owner_user_id=$3" + args = append(args, uid) + } + w := t.one(q, args...) + require(w != nil, 404, "not_found") + return w +} + +func version(o Object, v int64) { + require(v > 0, 428, "version_required") + require(o.N("version") == v, 409, "version_conflict") +} + +func idleProject(t *transaction, pid string) { + require(t.one("SELECT id FROM operations WHERE project_id=$1 AND state IN ('queued','running','retry_wait','blocked')", pid) == nil, 409, "operation_in_progress") +} + +// ErrorCode normalizes failures for HTTP without leaking SQL or credentials. +func ErrorCode(err error) *Fault { + var f *Fault + if errors.As(err, &f) { + return f + } + return &Fault{Code: "internal_error", Params: Object{}, Status: 500} +} + +func requestHash(method, path string, body Object) string { + b := method + "\n" + path + "\n" + jsonText(body) + h := sha256.Sum256([]byte(b)) + return fmt.Sprintf("%x", h) +} diff --git a/internal/logger/README.md b/internal/logger/README.md new file mode 100644 index 0000000..2910eec --- /dev/null +++ b/internal/logger/README.md @@ -0,0 +1,18 @@ +# internal/logger: Structured Logging Subsystem + +`internal/logger` provides process-wide structured logging for Ora Cloud, wrapping Uber Zap and Lumberjack. + +## Responsibilities + +- **Dual-sink composition**: + - **Console sink**: Emits colorized, ISO8601 human-readable log lines to `stdout` for local development and container console output. + - **File sink**: Emits machine-readable JSON log events with log rotation managed by Lumberjack (configurable `max_size`, `max_backups`, `max_age`, and gzip compression). +- **Standardized event fields**: Includes ISO8601 timestamps, log levels, short caller locations, error stack traces, and request correlation IDs (`requestId`). +- **Platform-safe buffer flushing**: `Sync(log)` cleanly flushes buffered entries on shutdown, explicitly handling and suppressing the Windows console handle `EINVAL` error that occurs when syncing console outputs. + +## Boundaries and invariants + +- **No secret leakage**: Callers must never pass raw credentials, passwords, auth tokens, or unredacted SQL queries to logger calls. +- **Explicit ownership**: Loggers are constructed in command entrypoints and passed explicitly to HTTP routers and middleware; there are no hidden package-global logger singletons. + +See [Logger config](../config/README.md) and [cmd/server](../../cmd/server/README.md). diff --git a/pkg/logger/logger.go b/internal/logger/logger.go similarity index 77% rename from pkg/logger/logger.go rename to internal/logger/logger.go index d76fb28..132332a 100644 --- a/pkg/logger/logger.go +++ b/internal/logger/logger.go @@ -1,23 +1,17 @@ -// Package logger provides structured logging functionality via Uber Zap and Lumberjack. +// Package logger provides process-scoped structured logging via Uber Zap and Lumberjack. package logger import ( + "errors" "os" + "syscall" "go.uber.org/zap" "go.uber.org/zap/zapcore" lumberjack "gopkg.in/natefinch/lumberjack.v2" ) -// Global logger instances. -var ( - // Log is the global zap logger. - Log *zap.Logger - // Sugar is the global sugared zap logger. - Sugar *zap.SugaredLogger -) - -// Config defines logger configuration parameters +// Config defines logger configuration parameters. type Config struct { Level string `mapstructure:"level" json:"level" yaml:"level"` Filename string `mapstructure:"filename" json:"filename" yaml:"filename"` @@ -28,8 +22,8 @@ type Config struct { EnableConsole bool `mapstructure:"enable_console" json:"enable_console" yaml:"enable_console"` // write to stdout as well } -// Init initializes the global zap logger with Lumberjack rotation -func Init(cfg Config) (*zap.Logger, error) { +// New constructs an independently owned zap logger with Lumberjack rotation. +func New(cfg Config) (*zap.Logger, error) { var level zapcore.Level if err := level.UnmarshalText([]byte(cfg.Level)); err != nil { level = zapcore.InfoLevel @@ -75,15 +69,17 @@ func Init(cfg Config) (*zap.Logger, error) { } core := zapcore.NewTee(cores...) - Log = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(0)) - Sugar = Log.Sugar() - - return Log, nil + return zap.New(core, zap.AddCaller()), nil } -// Sync flushes any buffered log entries -func Sync() { - if Log != nil { - _ = Log.Sync() +// Sync flushes buffered entries from log. Windows console handles can report EINVAL after a +// successful flush, so that platform error is treated as a completed sync. +func Sync(log *zap.Logger) error { + if log == nil { + return nil + } + if err := log.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) { + return err } + return nil } diff --git a/internal/model/user.go b/internal/model/user.go deleted file mode 100644 index e0f9542..0000000 --- a/internal/model/user.go +++ /dev/null @@ -1,31 +0,0 @@ -// Package model defines data structures and domain entities. -package model - -import ( - "time" - - "gorm.io/gorm" -) - -// User represents user entity in the database -type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:64;not null;uniqueIndex" json:"username"` - Nickname string `gorm:"size:64" json:"nickname"` - Email string `gorm:"size:128;uniqueIndex" json:"email"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// TableName sets table name for User -func (User) TableName() string { - return "users" -} - -// CreateUserRequest defines request payload for creating user -type CreateUserRequest struct { - Username string `json:"username" binding:"required,min=3,max=32"` - Nickname string `json:"nickname" binding:"max=32"` - Email string `json:"email" binding:"required,email"` -} diff --git a/internal/repository/README.md b/internal/repository/README.md new file mode 100644 index 0000000..5b0eb79 --- /dev/null +++ b/internal/repository/README.md @@ -0,0 +1,21 @@ +# internal/repository: PostgreSQL Connection & Pool Management + +`internal/repository` owns the initialization, configuration, and verification of PostgreSQL connection pools for Ora Cloud. + +## Responsibilities + +- **Connection setup**: `InitDB` establishes the connection to PostgreSQL using GORM's PostgreSQL driver with the configured DSN. +- **Connection pooling**: Configures standard database pool tuning parameters from `config.DatabaseConfig`: + - `SetMaxOpenConns`: Caps maximum concurrent open connections. + - `SetMaxIdleConns`: Maintains an optimal number of idle connections. + - `SetConnMaxLifetime`: Enforces connection turnover to respect database server connection policies. +- **Fail-fast health verification**: Performs `pool.PingContext(ctx)` during initialization. If the database is unreachable, the pool is closed immediately and a wrapped descriptive error is returned, preventing half-initialized daemon startup. +- **Silent GORM logging**: Silences GORM's internal loggers (`logger.Silent`). Application-level operational and error logging is owned exclusively by `internal/logger` and request lifecycle middleware. + +## Boundaries and invariants + +- **PostgreSQL exclusively**: `cfg.Driver` must equal `"postgres"`. In-memory databases, SQLite, MySQL, or mock database layers are rejected. +- **No schema mutations**: This package does **not** invoke GORM's `AutoMigrate` or issue DDL. Schema definitions and updates belong exclusively to `internal/core/migrations` and `cloudctl migrate`. +- **No domain queries**: Data access logic and transaction orchestration belong to `internal/core`. This package only delivers a verified `*gorm.DB` instance to `core.NewStore`. + +See [Core store](../core/README.md), [Database migrations](../core/migrations/README.md), and [Configuration](../config/README.md). diff --git a/internal/repository/db.go b/internal/repository/db.go index 4b23f8e..d20bd5b 100644 --- a/internal/repository/db.go +++ b/internal/repository/db.go @@ -1,143 +1,38 @@ -// Package repository provides data access and database persistence layer. +// Package repository owns PostgreSQL connections. Schema changes run via cloudctl migrate. package repository import ( "context" - "errors" "fmt" - "time" - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/driver/mysql" + "gorm.io/driver/postgres" "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" + "gorm.io/gorm/logger" "github.com/wanglongan587/cloud/internal/config" - "github.com/wanglongan587/cloud/internal/model" - "github.com/wanglongan587/cloud/pkg/logger" ) -// DB is the global database instance -var DB *gorm.DB - -// InitDB initializes database connection and pool -func InitDB(cfg config.DatabaseConfig) (*gorm.DB, error) { - var dialector gorm.Dialector - - switch cfg.Driver { - case "mysql": - dialector = mysql.Open(cfg.DSN) - case "sqlite": - dialector = sqlite.Open(cfg.DSN) - default: - return nil, fmt.Errorf("unsupported database driver: %s", cfg.Driver) +// InitDB opens and verifies a pool without performing schema mutations. +func InitDB(ctx context.Context, cfg config.DatabaseConfig) (*gorm.DB, error) { + if cfg.Driver != "postgres" { + return nil, fmt.Errorf("database.driver must be postgres") } - - gormConfig := &gorm.Config{ - Logger: NewGormZapLogger(logger.Log), - } - - db, err := gorm.Open(dialector, gormConfig) + db, err := gorm.Open(postgres.Open(cfg.DSN), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { - return nil, fmt.Errorf("failed to connect database: %w", err) + return nil, fmt.Errorf("open PostgreSQL: %w", err) } - - sqlDB, err := db.DB() + pool, err := db.DB() if err != nil { - return nil, fmt.Errorf("failed to get sql.DB: %w", err) - } - - // Set connection pool - if cfg.MaxIdleConns > 0 { - sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) - } - if cfg.MaxOpenConns > 0 { - sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) - } - if cfg.ConnMaxLifetime > 0 { - sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) - } - - // Auto migration - if cfg.AutoMigrate { - if err := db.AutoMigrate(&model.User{}); err != nil { - return nil, fmt.Errorf("failed to auto migrate: %w", err) + return nil, fmt.Errorf("get PostgreSQL pool: %w", err) + } + pool.SetMaxOpenConns(cfg.MaxOpenConns) + pool.SetMaxIdleConns(cfg.MaxIdleConns) + pool.SetConnMaxLifetime(cfg.ConnMaxLifetime) + if err = pool.PingContext(ctx); err != nil { + if closeErr := pool.Close(); closeErr != nil { + return nil, fmt.Errorf("ping PostgreSQL: %w (close pool: %v)", err, closeErr) } - logger.Log.Info("Database auto migration completed") + return nil, fmt.Errorf("ping PostgreSQL: %w", err) } - - DB = db return db, nil } - -// GormZapLogger integrates zap with gorm -type GormZapLogger struct { - ZapLogger *zap.Logger - LogLevel gormlogger.LogLevel - SlowThreshold time.Duration -} - -// NewGormZapLogger creates a GormZapLogger instance -func NewGormZapLogger(l *zap.Logger) gormlogger.Interface { - return &GormZapLogger{ - ZapLogger: l, - LogLevel: gormlogger.Info, - SlowThreshold: 200 * time.Millisecond, - } -} - -func (l *GormZapLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { - newLogger := *l - newLogger.LogLevel = level - return &newLogger -} - -func (l *GormZapLogger) Info(ctx context.Context, msg string, data ...any) { - if l.LogLevel >= gormlogger.Info { - l.ZapLogger.Sugar().Infof(msg, data...) - } -} - -func (l *GormZapLogger) Warn(ctx context.Context, msg string, data ...any) { - if l.LogLevel >= gormlogger.Warn { - l.ZapLogger.Sugar().Warnf(msg, data...) - } -} - -func (l *GormZapLogger) Error(ctx context.Context, msg string, data ...any) { - if l.LogLevel >= gormlogger.Error { - l.ZapLogger.Sugar().Errorf(msg, data...) - } -} - -func (l *GormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { - if l.LogLevel <= gormlogger.Silent { - return - } - - elapsed := time.Since(begin) - sql, rows := fc() - - switch { - case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.LogLevel >= gormlogger.Error: - l.ZapLogger.Error("gorm trace error", - zap.Error(err), - zap.Duration("elapsed", elapsed), - zap.Int64("rows", rows), - zap.String("sql", sql), - ) - case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn: - l.ZapLogger.Warn("gorm slow sql query", - zap.Duration("elapsed", elapsed), - zap.Int64("rows", rows), - zap.String("sql", sql), - ) - case l.LogLevel >= gormlogger.Info: - l.ZapLogger.Debug("gorm sql query", - zap.Duration("elapsed", elapsed), - zap.Int64("rows", rows), - zap.String("sql", sql), - ) - } -} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go deleted file mode 100644 index 3916ce0..0000000 --- a/internal/repository/user_repository.go +++ /dev/null @@ -1,74 +0,0 @@ -package repository - -import ( - "context" - - "gorm.io/gorm" - - "github.com/wanglongan587/cloud/internal/model" -) - -// UserRepository defines interface for user data operations -type UserRepository interface { - Create(ctx context.Context, user *model.User) error - GetByID(ctx context.Context, id uint) (*model.User, error) - GetByUsername(ctx context.Context, username string) (*model.User, error) - List(ctx context.Context, offset, limit int) ([]*model.User, int64, error) - Update(ctx context.Context, user *model.User) error - Delete(ctx context.Context, id uint) error -} - -type userRepository struct { - db *gorm.DB -} - -// NewUserRepository creates a new UserRepository instance -func NewUserRepository(db *gorm.DB) UserRepository { - return &userRepository{db: db} -} - -func (r *userRepository) Create(ctx context.Context, user *model.User) error { - return r.db.WithContext(ctx).Create(user).Error -} - -func (r *userRepository) GetByID(ctx context.Context, id uint) (*model.User, error) { - var user model.User - err := r.db.WithContext(ctx).First(&user, id).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) GetByUsername(ctx context.Context, username string) (*model.User, error) { - var user model.User - err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *userRepository) List(ctx context.Context, offset, limit int) ([]*model.User, int64, error) { - var users []*model.User - var total int64 - - db := r.db.WithContext(ctx).Model(&model.User{}) - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - - if err := db.Offset(offset).Limit(limit).Order("id desc").Find(&users).Error; err != nil { - return nil, 0, err - } - - return users, total, nil -} - -func (r *userRepository) Update(ctx context.Context, user *model.User) error { - return r.db.WithContext(ctx).Save(user).Error -} - -func (r *userRepository) Delete(ctx context.Context, id uint) error { - return r.db.WithContext(ctx).Delete(&model.User{}, id).Error -} diff --git a/internal/service/user_service.go b/internal/service/user_service.go deleted file mode 100644 index 60d2864..0000000 --- a/internal/service/user_service.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package service implements business logic. -package service - -import ( - "context" - "errors" - - "github.com/wanglongan587/cloud/internal/model" - "github.com/wanglongan587/cloud/internal/repository" -) - -// UserService defines interface for user business logic -type UserService interface { - CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) - GetUser(ctx context.Context, id uint) (*model.User, error) - ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) -} - -type userService struct { - repo repository.UserRepository -} - -// NewUserService creates a new UserService instance -func NewUserService(repo repository.UserRepository) UserService { - return &userService{repo: repo} -} - -func (s *userService) CreateUser(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) { - // Check if username already exists - existing, _ := s.repo.GetByUsername(ctx, req.Username) - if existing != nil { - return nil, errors.New("username already exists") - } - - user := &model.User{ - Username: req.Username, - Nickname: req.Nickname, - Email: req.Email, - } - - if err := s.repo.Create(ctx, user); err != nil { - return nil, err - } - - return user, nil -} - -func (s *userService) GetUser(ctx context.Context, id uint) (*model.User, error) { - return s.repo.GetByID(ctx, id) -} - -func (s *userService) ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) { - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 10 - } - offset := (page - 1) * pageSize - - return s.repo.List(ctx, offset, pageSize) -} diff --git a/internal/simulator/README.md b/internal/simulator/README.md new file mode 100644 index 0000000..cb23661 --- /dev/null +++ b/internal/simulator/README.md @@ -0,0 +1,33 @@ +# internal/simulator: Execution Doubles for Development & Testing + +`internal/simulator` provides in-process and disk-backed execution doubles representing the Controller, Workspace Node, and Substrate storage systems for Ora Cloud's phase-one development and acceptance testing. + +## Responsibilities + +### Substrate double (`substrate.go`) +- Simulates external storage and effect journal execution over local HTTP. +- Manages effect journal JSON files on disk (`/effects/.json`). +- Executes mock infrastructure operations: + - **Storage**: Prepares local project directories (`/projects/`). + - **Worktree**: Performs real Git clone and worktree checkouts using local Git CLI. + - **Sandbox**: Simulates sandbox instance allocation and termination lifecycles. +- Supports deterministic fault injection (`SetFault`) for testing error recovery and retry policies. + +### Controller double (`controller.go`) +- Simulates the external control plane worker: + - Periodically acquires and renews controller leases via `/internal/v1/controller-lease/acquire`. + - Claims pending operations via `/internal/v1/operations/claim`. + - Plans and executes required effects against Substrate. + - Reports effect execution outcomes via `/internal/v1/operations/{oid}/effects/{eid}/result`. + - Advances or defers operations with proper monotonic epoch fencing. + +### Ephemeral credential issuer +- `NewCredentials()` generates in-memory Ed25519 cryptographic keypairs for the four distinct actor roles: `gateway`, `controller`, `node`, and `user`. +- Signs short-lived JWT tokens on demand for simulator test runs, matching production cryptographic token structures without requiring external authentication infrastructure. + +## Boundaries and invariants + +- **Development and testing only**: This package is an execution double. It is never deployed to production environments or imported by production daemon binaries. +- **Contract fidelity**: The simulator interacts with the core cloud server strictly over standard HTTP APIs and respects all leasing, fencing, and idempotency contracts. + +See [cmd/simulator](../../cmd/simulator/README.md), [Execution contract](../../docs/execution-contract.md), and [Integration tests](../../integration/README.md). diff --git a/internal/simulator/controller.go b/internal/simulator/controller.go new file mode 100644 index 0000000..35041a7 --- /dev/null +++ b/internal/simulator/controller.go @@ -0,0 +1,399 @@ +package simulator + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/wanglongan587/cloud/internal/core" +) + +// Credentials are generated only for the isolated simulator/test process. +type Credentials struct { + Private map[string]ed25519.PrivateKey + Trust []core.TrustedKey +} + +func NewCredentials() (*Credentials, error) { + c := &Credentials{Private: map[string]ed25519.PrivateKey{}} + for _, role := range []string{"gateway", "controller", "node", "user"} { + pub, key, e := ed25519.GenerateKey(rand.Reader) + if e != nil { + return nil, e + } + kind := "service" + if role == "user" { + kind = "user" + } + c.Private[role] = key + c.Trust = append(c.Trust, core.TrustedKey{ID: role, Issuer: "ora-simulator", Kind: kind, Role: role, Key: pub}) + } + return c, nil +} + +// Token signs short-lived claims for one known simulator role. +// +//nolint:gocritic // Copy before adding timestamps; signing concurrent requests must not mutate shared identity claims. +func (c *Credentials) Token(role string, claims core.Claims) (string, error) { + key, ok := c.Private[role] + if !ok { + return "", fmt.Errorf("unknown simulator credential role %q", role) + } + now := time.Now() + claims.RegisteredClaims = jwt.RegisteredClaims{Issuer: "ora-simulator", Subject: claims.Subject, Audience: jwt.ClaimStrings{"ora-cloud"}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute))} + claims.Kind = "service" + claims.Role = role + if role == "user" { + claims.Kind = "user" + claims.Role = "" + } + t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + t.Header["kid"] = role + raw, err := t.SignedString(key) + if err != nil { + return "", fmt.Errorf("sign %s simulator credential: %w", role, err) + } + return raw, nil +} + +// Client calls cloud through actual HTTP, issuing short-lived internal simulator credentials. +type Client struct { + URL string + Credentials *Credentials + HTTP *http.Client + Subject string +} + +//nolint:gocritic // Value claims intentionally isolate each signed request from concurrent callers. +func (c *Client) Call(ctx context.Context, method, path, role string, claims core.Claims, user *core.Claims, key string, body core.Object) (core.Object, int, error) { + raw := jsonString(body) + req, e := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewBufferString(raw)) + if e != nil { + return nil, 0, e + } + req.Header.Set("Content-Type", "application/json") + token, e := c.Credentials.Token(role, claims) + if e != nil { + return nil, 0, e + } + req.Header.Set("Authorization", "Bearer "+token) + if user != nil { + u := *user + u.Caller = claims.Subject + userToken, tokenErr := c.Credentials.Token("user", u) + if tokenErr != nil { + return nil, 0, tokenErr + } + req.Header.Set("X-Ora-User-Token", userToken) + } + if key != "" { + req.Header.Set("Idempotency-Key", key) + } + res, e := c.HTTP.Do(req) + if e != nil { + return nil, 0, e + } + defer res.Body.Close() + out := core.Object{} + e = json.NewDecoder(res.Body).Decode(&out) + if e != nil { + return nil, res.StatusCode, e + } + return out, res.StatusCode, nil +} + +func (c *Client) Control(ctx context.Context, path string, body core.Object) (core.Object, error) { + out, status, e := c.Call(ctx, "POST", path, "controller", core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: c.Subject}}, nil, "", body) + if e != nil { + return nil, e + } + if status != 200 { + return nil, fmt.Errorf("cloud %s: %d %s", path, status, jsonString(out)) + } + return out, nil +} + +// Controller has no database handle; recovery always queries external effect IDs before dispatch. +type Controller struct { + Client *Client + SubstrateURL string + Epoch int64 + Operation core.Object +} + +func (c *Controller) Acquire(ctx context.Context) error { + o, e := c.Client.Control(ctx, "/internal/v1/controller-lease/acquire", core.Object{}) + if e == nil { + c.Epoch = o.N("epoch") + } + return e +} + +func (c *Controller) command(ctx context.Context, suffix string, b core.Object) (core.Object, error) { + b["epoch"], b["version"] = c.Epoch, c.Operation.N("version") + return c.Client.Control(ctx, "/internal/v1/operations/"+c.Operation.S("id")+suffix, b) +} + +func (c *Controller) external(ctx context.Context, method, id string, b core.Object) (core.Object, int, error) { + req, e := http.NewRequestWithContext(ctx, method, c.SubstrateURL+"/effects/"+id, bytes.NewBufferString(jsonString(b))) + if e != nil { + return nil, 0, e + } + req.Header.Set("Content-Type", "application/json") + resp, e := c.Client.HTTP.Do(req) + if e != nil { + return nil, 0, e + } + defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, 404, nil + } + data, e := io.ReadAll(resp.Body) + if e != nil { + return nil, resp.StatusCode, e + } + out := core.Object{} + if len(data) > 0 { + e = json.Unmarshal(data, &out) + } + return out, resp.StatusCode, e +} + +func objects(o core.Object, k string) ([]core.Object, error) { + var out []core.Object + switch a := o[k].(type) { + case nil: + return nil, nil + case []any: + for i, v := range a { + value, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("cloud field %q item %d is not an object", k, i) + } + out = append(out, core.Object(value)) + } + case []core.Object: + out = a + default: + return nil, fmt.Errorf("cloud field %q is not an array", k) + } + return out, nil +} + +func (c *Controller) submit(ctx context.Context, effect, external core.Object) error { + b := core.Object{"state": external.S("state"), "externalId": external.S("externalId"), "result": external.O("result")} + out, e := c.command(ctx, "/effects/"+effect.S("id")+"/result", b) + if e == nil { + c.Operation = out.O("operation") + } + return e +} + +func (c *Controller) deferExternalFailure(ctx context.Context, kind string, status int, external core.Object, cause error) error { + state, code := "retry_wait", "external_failure" + if cause != nil || status == http.StatusGatewayTimeout || external.S("state") == "running" { + code = "substrate_timeout" + } + if kind == "worktree_delete" && cause == nil { + code = "git_cleanup_failed" + } + if kind == "sandbox_terminate" && external.S("state") == "running" { + state, code = "blocked", "termination_unconfirmed" + } + _, deferErr := c.command(ctx, "/defer", core.Object{"state": state, "errorCode": code, "retrySeconds": 1}) + if deferErr == nil { + c.Operation = nil + } + if cause != nil { + return errors.Join(fmt.Errorf("substrate %s request: %w", kind, cause), deferErr) + } + return errors.Join(fmt.Errorf("substrate %s: HTTP %d; stable effect remains tracked: %s", kind, status, external.S("diagnostic")), deferErr) +} + +// Step makes one bounded transition; callers can stop/restart between every persisted stage. +func (c *Controller) Step(ctx context.Context) (bool, error) { + if _, e := c.Client.Control(ctx, "/internal/v1/controller-lease/renew", core.Object{"epoch": c.Epoch}); e != nil { + return false, e + } + var snap core.Object + var err error + if c.Operation == nil { + snap, err = c.Client.Control(ctx, "/internal/v1/operations/claim", core.Object{"epoch": c.Epoch}) + if err != nil { + return false, err + } + if snap["operation"] == nil { + return true, nil + } + c.Operation = snap.O("operation") + } else { + snap, err = c.command(ctx, "/snapshot", core.Object{}) + if err != nil { + return false, err + } + } + effects, err := objects(snap, "effects") + if err != nil { + return false, err + } + workspaces, err := objects(snap, "workspaces") + if err != nil { + return false, err + } + sandboxes, err := objects(snap, "sandboxes") + if err != nil { + return false, err + } + nodes, err := objects(snap, "nodes") + if err != nil { + return false, err + } + for _, effect := range effects { + if effect.N("reconciledEpoch") == c.Epoch { + continue + } + external, status, e := c.external(ctx, "GET", effect.S("id"), nil) + if e != nil { + return false, c.deferExternalFailure(ctx, effect.S("kind"), status, external, e) + } + if status == 404 { + external = core.Object{"state": "absent", "result": core.Object{}} + } + if e := c.submit(ctx, effect, external); e != nil { + return false, e + } + } + step := c.Operation.S("step") + switch step { + case "node": + for _, sandbox := range sandboxes { + if sandbox.S("workspaceId") != c.Operation.S("workspaceId") || sandbox["terminatedAt"] != nil { + continue + } + effect := core.Object{} + for _, candidate := range effects { + if candidate.S("kind") == "sandbox_ensure" { + effect = candidate + } + } + nodeID := effect.O("result").S("nodeId") + if nodeID == "" { + return false, fmt.Errorf("simulator node id missing") + } + claims := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: nodeID}, WorkspaceID: sandbox.S("workspaceId"), SandboxID: sandbox.S("id"), Generation: sandbox.N("generation")} + n, status, e := c.Client.Call(ctx, "POST", "/internal/v1/nodes/register", "node", claims, nil, "", core.Object{"protocolVersion": 1}) + if e != nil || status != 200 { + return false, fmt.Errorf("node registration: %d %v %s", status, e, jsonString(n)) + } + n, status, e = c.Client.Call(ctx, "POST", "/internal/v1/nodes/status", "node", claims, nil, "", core.Object{"version": n.N("version"), "connectionState": "connected", "initialized": true}) + if e != nil || status != 200 { + return false, fmt.Errorf("node status: %d %v %s", status, e, jsonString(n)) + } + } + case "quiesce": + for _, w := range workspaces { + if c.Operation.S("workspaceId") != "" && w.S("id") != c.Operation.S("workspaceId") { + continue + } + for _, sandbox := range sandboxes { + if sandbox.S("workspaceId") != w.S("id") || sandbox["terminatedAt"] != nil { + continue + } + for _, n := range nodes { + if n.S("sandboxInstanceId") != sandbox.S("id") || n["endedAt"] != nil { + continue + } + claims := core.Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: n.S("id")}, WorkspaceID: w.S("id"), SandboxID: sandbox.S("id"), Generation: sandbox.N("generation")} + out, status, e := c.Client.Call(ctx, "POST", "/internal/v1/nodes/idle", "node", claims, nil, "", core.Object{"version": n.N("version"), "admissionEpoch": w.N("admissionEpoch"), "operationId": c.Operation.S("id"), "idle": true}) + if e != nil || status != 200 { + return false, fmt.Errorf("node idle: %d %v %s", status, e, jsonString(out)) + } + } + } + } + default: + kinds := map[string]string{"storage": "storage_ensure", "worktree": "worktree_ensure", "sandbox": "sandbox_ensure", "terminate": "sandbox_terminate", "cleanup": "worktree_delete", "storage_delete": "storage_delete"} + kind := kinds[step] + if kind == "" { + return false, fmt.Errorf("unknown step %s", step) + } + targets := []core.Object{{}} + if step != "storage" && step != "storage_delete" { + targets = nil + for _, w := range workspaces { + if c.Operation.S("workspaceId") == "" || c.Operation.S("workspaceId") == w.S("id") { + targets = append(targets, w) + } + } + } + for _, w := range targets { + var sandbox core.Object + if step == "terminate" { + for _, v := range sandboxes { + if v.S("workspaceId") == w.S("id") && v["terminatedAt"] == nil { + sandbox = v + } + } + if sandbox == nil { + continue + } + } + planned, e := c.command(ctx, "/effects", core.Object{"kind": kind, "workspaceId": w.S("id")}) + if e != nil { + return false, e + } + c.Operation = planned.O("operation") + effect := planned.O("effect") + external, status, e := c.external(ctx, "GET", effect.S("id"), nil) + if e != nil { + return false, c.deferExternalFailure(ctx, kind, status, external, e) + } + if status == 404 || external.S("state") != "succeeded" { + if _, e = c.command(ctx, "/snapshot", core.Object{}); e != nil { + return false, e + } + external, status, e = c.external(ctx, "PUT", effect.S("id"), effect.O("request")) + if e != nil { + return false, c.deferExternalFailure(ctx, kind, status, external, e) + } + if status != 200 { + return false, c.deferExternalFailure(ctx, kind, status, external, nil) + } + } + if e := c.submit(ctx, effect, external); e != nil { + return false, e + } + } + } + out, e := c.command(ctx, "/advance", core.Object{}) + if e != nil { + return false, e + } + c.Operation = out + if out.S("state") == "succeeded" { + c.Operation = nil + } + return false, nil +} + +func (c *Controller) Drain(ctx context.Context) error { + for i := 0; i < 100; i++ { + done, e := c.Step(ctx) + if e != nil { + return e + } + if done { + return nil + } + } + return fmt.Errorf("simulation exceeded transition bound") +} diff --git a/internal/simulator/controller_test.go b/internal/simulator/controller_test.go new file mode 100644 index 0000000..75d1d40 --- /dev/null +++ b/internal/simulator/controller_test.go @@ -0,0 +1,25 @@ +package simulator + +import ( + "strings" + "testing" + + "github.com/wanglongan587/cloud/internal/core" +) + +func TestObjectsRejectsMalformedCloudResponse(t *testing.T) { + _, err := objects(core.Object{"effects": []any{"not-an-object"}}, "effects") + if err == nil || !strings.Contains(err.Error(), "item 0 is not an object") { + t.Fatalf("malformed response was not rejected: %v", err) + } +} + +func TestCredentialsRejectUnknownRole(t *testing.T) { + credentials, err := NewCredentials() + if err != nil { + t.Fatal(err) + } + if _, err = credentials.Token("unknown", core.Claims{}); err == nil { + t.Fatal("unknown simulator role was signed") + } +} diff --git a/internal/simulator/substrate.go b/internal/simulator/substrate.go new file mode 100644 index 0000000..eba927f --- /dev/null +++ b/internal/simulator/substrate.go @@ -0,0 +1,304 @@ +// Package simulator provides phase-one HTTP execution doubles backed by disk and real Git. +// It is test/development infrastructure, not a production Controller, Node, or Substrate. +package simulator + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" +) + +// Substrate persists stable effect journals and project data outside cloud's database. +type Substrate struct { + Root string + Repositories map[string]string + mu sync.Mutex + faults map[string]string +} + +func NewSubstrate(root string, repos map[string]string) (*Substrate, error) { + absolute, e := filepath.Abs(root) + if e != nil { + return nil, e + } + if e := os.MkdirAll(filepath.Join(absolute, "effects"), 0o700); e != nil { + return nil, e + } + return &Substrate{Root: absolute, Repositories: repos, faults: map[string]string{}}, nil +} + +// SetFault injects explicit simulator failures; never used by cloud core. +func (s *Substrate) SetFault(kind, mode string) { + s.mu.Lock() + defer s.mu.Unlock() + s.faults[kind] = mode +} + +func (s *Substrate) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + id := strings.TrimPrefix(r.URL.Path, "/effects/") + if _, e := uuid.Parse(id); e != nil { + http.Error(w, "invalid id", 400) + return + } + file := filepath.Join(s.Root, "effects", id+".json") + old, err := readObject(file) + if r.Method == "GET" { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(404) + return + } + if err != nil { + http.Error(w, "journal failure", 500) + return + } + writeJSON(w, old) + return + } + if r.Method != "PUT" { + w.WriteHeader(405) + return + } + body := core.Object{} + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if json.NewDecoder(r.Body).Decode(&body) != nil { + w.WriteHeader(400) + return + } + for _, key := range []string{"projectId"} { + if _, e := uuid.Parse(body.S(key)); e != nil { + w.WriteHeader(400) + return + } + } + if body.S("workspaceId") != "" { + if _, e := uuid.Parse(body.S("workspaceId")); e != nil { + w.WriteHeader(400) + return + } + } + if old != nil { + if jsonString(old.O("request")) != jsonString(body) { + w.WriteHeader(409) + return + } + if old.S("state") == "succeeded" { + writeJSON(w, old) + return + } + } + effect := core.Object{"id": id, "externalId": "sim-" + id, "state": "running", "request": body, "result": core.Object{}} + if err = writeObject(file, effect); err != nil { + http.Error(w, "journal failure", 500) + return + } + mode := s.faults[body.S("kind")] + if mode == "fail" || mode == "unconfirmed" { + w.WriteHeader(503) + writeJSON(w, effect) + return + } + result, err := s.perform(r.Context(), id, body) + if err != nil { + effect["state"] = "failed" + effect["error"] = "external_failure" + effect["diagnostic"] = err.Error() + if err = writeObject(file, effect); err != nil { + http.Error(w, "journal failure", 500) + return + } + w.WriteHeader(503) + writeJSON(w, effect) + return + } + effect["result"] = result + effect["state"] = "succeeded" + if err = writeObject(file, effect); err != nil { + http.Error(w, "journal failure", 500) + return + } + if mode == "lose_response" { + w.WriteHeader(504) + return + } + writeJSON(w, effect) +} +func writeJSON(w http.ResponseWriter, v any) { _ = json.NewEncoder(w).Encode(v) } +func jsonString(v any) string { b, _ := json.Marshal(v); return string(b) } +func readObject(path string) (core.Object, error) { + b, e := os.ReadFile(filepath.Clean(path)) // #nosec G304,G703 -- simulator helper reading from isolated test root. + if e != nil { + return nil, e + } + var o core.Object + e = json.Unmarshal(b, &o) + return o, e +} + +func writeObject(path string, o core.Object) error { + b, e := json.Marshal(o) + if e != nil { + return e + } + temp := filepath.Clean(path + ".tmp") + if e := os.WriteFile(temp, b, 0o600); e != nil { // #nosec G304,G703 -- simulator helper writing to isolated test root. + return e + } + f, e := os.OpenFile(temp, os.O_RDWR, 0o600) // #nosec G304,G703 -- simulator helper writing to isolated test root. + if e != nil { + return e + } + e = f.Sync() + ce := f.Close() + if e != nil { + return e + } + if ce != nil { + return ce + } + return os.Rename(temp, filepath.Clean(path)) +} + +func git(ctx context.Context, args ...string) (string, error) { + args = append([]string{"-c", "core.longpaths=true"}, args...) + cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204,G702 -- simulator helper executing git fixture operations. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1") + b, e := cmd.CombinedOutput() + if e != nil { + return "", fmt.Errorf("git command failed: %w: %s", e, b) + } + return strings.TrimSpace(string(b)), nil +} +func exists(path string) bool { _, e := os.Stat(path); return e == nil } +func (s *Substrate) perform(ctx context.Context, id string, b core.Object) (core.Object, error) { + pid, wid := b.S("projectId"), b.S("workspaceId") + root := filepath.Join(s.Root, "projects", pid) + bare := filepath.Join(root, "repository.git") + checkout := filepath.Join(root, "workspaces", wid, "checkout") + runtime := filepath.Join(root, "workspaces", wid, "runtime") + out := core.Object{} + switch b.S("kind") { + case "storage_ensure": + if e := os.MkdirAll(root, 0o700); e != nil { + return nil, e + } + out["layoutVersion"] = 1 + case "worktree_ensure": + if wid == "" { + return nil, fmt.Errorf("workspace required") + } + source, ok := s.Repositories[b.S("repositoryUrl")] + if !ok { + return nil, fmt.Errorf("repository must be explicitly mapped for simulator") + } + if !exists(bare) { + if _, e := git(ctx, "clone", "--bare", "--", source, bare); e != nil { + return nil, e + } + } + ref := b.S("requestedRef") + if ref == "" || strings.HasPrefix(ref, "-") || strings.ContainsAny(ref, "\r\n\x00") { + return nil, fmt.Errorf("invalid ref") + } + branch := "ora/" + wid + if !exists(checkout) { + if e := os.MkdirAll(filepath.Dir(checkout), 0o700); e != nil { + return nil, e + } + commit, e := git(ctx, "--git-dir", bare, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}") + if e != nil { + return nil, e + } + if _, e = git(ctx, "--git-dir", bare, "show-ref", "--verify", "refs/heads/"+branch); e == nil { + _, e = git(ctx, "--git-dir", bare, "worktree", "add", "--", checkout, branch) + } else { + _, e = git(ctx, "--git-dir", bare, "worktree", "add", "-b", branch, "--", checkout, commit) + } + if e != nil { + return nil, e + } + } + commit, e := git(ctx, "-C", checkout, "rev-parse", "HEAD") + if e != nil { + return nil, e + } + if e := os.MkdirAll(runtime, 0o700); e != nil { + return nil, e + } + out["commitId"], out["jobTerminated"] = commit, true + case "sandbox_ensure": + if wid == "" || !exists(checkout) { + return nil, fmt.Errorf("checkout required") + } + file := filepath.Join(s.Root, "effects", id+".sandbox.json") + sandbox, e := readObject(file) + if errors.Is(e, os.ErrNotExist) { + sandbox = core.Object{"id": id, "workspaceId": wid, "nodeId": uuid.NewString(), "terminated": false} + e = writeObject(file, sandbox) + } + if e != nil { + return nil, e + } + if sandbox.B("terminated") { + return nil, fmt.Errorf("sandbox terminated") + } + out["sandboxInstanceId"], out["nodeId"] = id, sandbox.S("nodeId") + case "sandbox_terminate": + sid := b.S("sandboxInstanceId") + if _, e := uuid.Parse(sid); e != nil { + return nil, e + } + file := filepath.Join(s.Root, "effects", sid+".sandbox.json") + sandbox, e := readObject(file) + if e != nil { + return nil, e + } + if sandbox.S("workspaceId") != wid { + return nil, fmt.Errorf("sandbox scope mismatch") + } + sandbox["terminated"] = true + if e := writeObject(file, sandbox); e != nil { + return nil, e + } + out["terminated"] = true + case "worktree_delete": + if wid == "" { + return nil, fmt.Errorf("workspace required") + } + if exists(checkout) { + if _, e := git(ctx, "--git-dir", bare, "worktree", "remove", "--force", "--", checkout); e != nil { + return nil, e + } + } + if _, e := git(ctx, "--git-dir", bare, "show-ref", "--verify", "refs/heads/ora/"+wid); e == nil { + if _, e = git(ctx, "--git-dir", bare, "branch", "-D", "--", "ora/"+wid); e != nil { + return nil, e + } + } + if e := os.RemoveAll(filepath.Join(root, "workspaces", wid)); e != nil { + return nil, e + } + out["jobTerminated"], out["removed"] = true, true + case "storage_delete": + if e := os.RemoveAll(root); e != nil { + return nil, e + } + out["removed"] = true + default: + return nil, fmt.Errorf("unknown effect kind") + } + return out, nil +} diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 0000000..173f6f3 --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,11 @@ +# pkg: Public & Reusable Libraries + +`pkg` is reserved for code that is intentionally reusable by external modules and whose API can be maintained and supported as a public surface. + +## Architectural policy and boundaries + +- **Private by default**: Per `AGENTS.md`, new implementation packages in Ora Cloud must be placed under `internal/`. +- **Public API commitment**: Code is moved or added to `pkg/` only when there is an explicit requirement to expose it as an importable library for external consumers (e.g., client SDKs, shared types, or common utilities). +- **Zero internal coupling**: Packages in `pkg/` must never import anything from `internal/` or `cmd/`. They must depend solely on the standard library and approved external dependencies. + +See [AGENTS.md](../AGENTS.md) and [Internal packages](../internal/README.md). diff --git a/pkg/response/response.go b/pkg/response/response.go deleted file mode 100644 index 6beafe9..0000000 --- a/pkg/response/response.go +++ /dev/null @@ -1,68 +0,0 @@ -// Package response provides standard HTTP response helpers. -package response - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// Response represents standard API JSON response body -type Response struct { - Code int `json:"code"` - Message string `json:"message"` - Data any `json:"data,omitempty"` -} - -// Common business response status codes. -const ( - // CodeSuccess indicates operation succeeded. - CodeSuccess = 0 - // CodeBadRequest indicates client request error. - CodeBadRequest = 400 - // CodeNotFound indicates resource not found. - CodeNotFound = 404 - // CodeServerError indicates internal server error. - CodeServerError = 500 -) - -// Success sends a success response with code 0 and HTTP status 200 -func Success(c *gin.Context, data any) { - c.JSON(http.StatusOK, Response{ - Code: CodeSuccess, - Message: "success", - Data: data, - }) -} - -// SuccessWithMessage sends a success response with a custom message -func SuccessWithMessage(c *gin.Context, message string, data any) { - c.JSON(http.StatusOK, Response{ - Code: CodeSuccess, - Message: message, - Data: data, - }) -} - -// Fail sends an error response with custom business code and message -func Fail(c *gin.Context, httpStatus, code int, message string) { - c.JSON(httpStatus, Response{ - Code: code, - Message: message, - }) -} - -// BadRequest sends HTTP 400 response -func BadRequest(c *gin.Context, message string) { - Fail(c, http.StatusBadRequest, CodeBadRequest, message) -} - -// ServerError sends HTTP 500 response -func ServerError(c *gin.Context, message string) { - Fail(c, http.StatusInternalServerError, CodeServerError, message) -} - -// NotFound sends HTTP 404 response -func NotFound(c *gin.Context, message string) { - Fail(c, http.StatusNotFound, CodeNotFound, message) -} diff --git a/scripts/Dockerfile b/scripts/Dockerfile index e18df56..1da7b58 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -1,32 +1,16 @@ -# Build stage -FROM golang:alpine AS builder - -WORKDIR /app - -# Enable Go modules and set GOPROXY -ENV GO111MODULE=on \ - GOPROXY=https://goproxy.cn,direct - -# Cache dependencies +FROM golang:1.27.1-alpine AS build +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download - -# Copy source code and build COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server cmd/server/main.go - -# Run stage -FROM alpine:latest +RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server && \ + CGO_ENABLED=0 go build -trimpath -o /out/cloudctl ./cmd/cloudctl +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 ora WORKDIR /app - -# Install basic dependencies (certificates, timezone) -RUN apk add --no-cache ca-certificates tzdata -ENV TZ=Asia/Shanghai - -COPY --from=builder /app/server /app/server -COPY --from=builder /app/configs /app/configs - +COPY --from=build /out/server /out/cloudctl /app/ +COPY configs/config.yaml /app/configs/config.yaml +USER ora EXPOSE 8080 - ENTRYPOINT ["/app/server"] diff --git a/scripts/Makefile b/scripts/Makefile index 38c4c1c..e4013f8 100644 --- a/scripts/Makefile +++ b/scripts/Makefile @@ -1,40 +1,7 @@ -.PHONY: build run test clean docker-build fmt lint check help - -BINARY_NAME=bin/server -MAIN_FILE=cmd/server/main.go -MODULE_NAME=github.com/wanglongan587/cloud - -help: - @echo "Available commands:" - @echo " make fmt - Format code using gofumpt & goimports" - @echo " make lint - Run static analysis via golangci-lint" - @echo " make check - Run full quality check (fmt + lint + test)" - @echo " make run - Run service locally" - @echo " make build - Compile the binary" - @echo " make test - Run all unit tests" - @echo " make clean - Remove build artifacts and logs" - @echo " make docker-build - Build Docker image" - +.PHONY: build run test check fmt lint docker-build +build run test check lint: + cd .. && task $@ fmt: - go run mvdan.cc/gofumpt -w -extra . - go run golang.org/x/tools/cmd/goimports -w -local $(MODULE_NAME) . - -lint: - go run github.com/golangci/golangci-lint/cmd/golangci-lint run -c .golangci.yml - -check: fmt lint test - -run: - go run $(MAIN_FILE) - -build: - go build -ldflags="-s -w" -o $(BINARY_NAME) $(MAIN_FILE) - -test: - go test -v ./... - -clean: - rm -rf bin/ logs/ *.db coverage.out - + cd .. && task format docker-build: - docker build -t cloud-service:latest -f scripts/Dockerfile . + cd .. && docker build -t ora-cloud:phase-one -f scripts/Dockerfile . diff --git a/scripts/postgres.ps1 b/scripts/postgres.ps1 new file mode 100644 index 0000000..7c7a4ed --- /dev/null +++ b/scripts/postgres.ps1 @@ -0,0 +1,37 @@ +param([ValidateSet('start','stop')][string]$Action='start') +$ErrorActionPreference='Stop' +$workspace=Split-Path $PSScriptRoot -Parent +$localRoot=Join-Path $workspace '.local' +$pgRoot=Join-Path $localRoot 'postgres' +$pgBin=Join-Path $pgRoot 'pgsql/bin' +$pgData=Join-Path $localRoot 'pgdata' +if ($Action -eq 'stop') { + & (Join-Path $pgBin 'pg_ctl.exe') -D $pgData stop -m fast + if ($LASTEXITCODE -ne 0) {throw 'PostgreSQL stop failed'} + exit +} +New-Item -ItemType Directory -Force $localRoot | Out-Null +if (-not (Test-Path (Join-Path $pgBin 'initdb.exe'))) { + $archive=Join-Path $localRoot 'postgres.zip' + if (-not (Test-Path $archive)) {Invoke-WebRequest 'https://sbp.enterprisedb.com/getfile.jsp?fileid=1260491' -OutFile $archive} + $expected='4B8DB0930C38F6EF845DB919551DEDDA3B6B845AEB0927B3D79A6E8E9E4537CF' + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $expected) {throw 'PostgreSQL 17.11 archive checksum mismatch'} + Expand-Archive -LiteralPath $archive -DestinationPath $pgRoot +} +if (-not (Test-Path (Join-Path $pgData 'PG_VERSION'))) { + & (Join-Path $pgBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --locale=C + if ($LASTEXITCODE -ne 0) {throw 'initdb failed'} +} +& (Join-Path $pgBin 'pg_isready.exe') -h 127.0.0.1 -p 55432 | Out-Null +if ($LASTEXITCODE -ne 0) { + $args=@('-D',('"'+$pgData+'"'),'-l',('"'+(Join-Path $localRoot 'pg.log')+'"'),'-o','"-h 127.0.0.1 -p 55432"','start','-w') + $pgLauncher=Start-Process -FilePath (Join-Path $pgBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden -PassThru + if (-not $pgLauncher.WaitForExit(20000)) {throw 'pg_ctl start timed out; inspect .local/pg.log'} + if ($pgLauncher.ExitCode -ne 0) {throw 'pg_ctl start failed; inspect .local/pg.log'} +} +$databaseExists=& (Join-Path $pgBin 'psql.exe') -h 127.0.0.1 -p 55432 -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='ora_test'" +if ($databaseExists -ne '1') { + & (Join-Path $pgBin 'createdb.exe') -h 127.0.0.1 -p 55432 -U postgres ora_test + if ($LASTEXITCODE -ne 0) {throw 'createdb failed'} +} +Write-Output "TEST_DATABASE_URL=host=127.0.0.1 port=55432 user=postgres dbname=ora_test sslmode=disable"