Compare commits
85
Commits
a0e7ae362b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0780ea51 | ||
|
|
e8ebf562c7 | ||
|
|
a8b5d483a3 | ||
|
|
20008b4043 | ||
|
|
b481b1b798 | ||
|
|
ab1cd01351 | ||
|
|
1488d5a43e | ||
|
|
5f2bac3280 | ||
|
|
4abdf75c77 | ||
|
|
a10878deee | ||
|
|
4b7739a274 | ||
|
|
e03b33cf25 | ||
|
|
312804c55e | ||
|
|
60d479db71 | ||
|
|
156392df1e | ||
|
|
4009b7260e | ||
|
|
c5d8141d2a | ||
|
|
bc45188be9 | ||
|
|
6488f6b31d | ||
|
|
6985f77963 | ||
|
|
300390dc4d | ||
|
|
0ce264836f | ||
|
|
f2a9c7b1ca | ||
|
|
c959a5bc03 | ||
|
|
839aff35ed | ||
|
|
59a8657297 | ||
|
|
05f5a97ba9 | ||
|
|
4ea27bda6a | ||
|
|
a822e9250a | ||
|
|
63e2340db7 | ||
|
|
ecdca8e28b | ||
|
|
b1f102bafb | ||
|
|
6e858e1b9c | ||
|
|
8e6d12f6f1 | ||
|
|
8c924b5928 | ||
|
|
e6e38b275a | ||
|
|
892c6d843f | ||
|
|
f88123e185 | ||
|
|
dfe4bd72d8 | ||
|
|
84380105fc | ||
|
|
4f20fcaf5b | ||
|
|
fd9dbab669 | ||
|
|
4ab7bb820d | ||
|
|
1b583f3ca6 | ||
|
|
f69429bfc7 | ||
|
|
86167a3a1b | ||
|
|
5be7ec112e | ||
|
|
49090fc3aa | ||
|
|
cf836715e2 | ||
|
|
4923308fb4 | ||
|
|
44f0b4a44b | ||
|
|
8dad79011a | ||
|
|
ba36668668 | ||
|
|
3cfb98ed47 | ||
|
|
14cbc63e61 | ||
|
|
80cddbf19d | ||
|
|
ec2462a312 | ||
|
|
fe09d21a56 | ||
|
|
bf3c382d15 | ||
|
|
5b82a42cc4 | ||
|
|
6311eb72ba | ||
|
|
a82f1ff01a | ||
|
|
6e614d3fa3 | ||
|
|
aeda833294 | ||
|
|
a027ca70eb | ||
|
|
6018d8f0fc | ||
|
|
40ac46ba17 | ||
|
|
7d9c1b7e9e | ||
|
|
3f348401a9 | ||
|
|
ea4e780562 | ||
|
|
bc927a5144 | ||
|
|
9691dbcb3d | ||
|
|
4e9a2bfeaa | ||
|
|
fa2ce17ce3 | ||
|
|
8194671656 | ||
|
|
8686419fb2 | ||
|
|
31797caeff | ||
|
|
35a272ce23 | ||
|
|
bc10cd7400 | ||
|
|
aafbec4c96 | ||
|
|
bb187f6007 | ||
|
|
3ca67907a1 | ||
|
|
dcbeb33dfc | ||
|
|
75ef3761ca | ||
|
|
d3b7207019 |
@@ -2,6 +2,8 @@
|
||||
# Copy to .env if you want docker compose to read custom values automatically.
|
||||
|
||||
PLATFORM_ADDR=:8080
|
||||
# Public Platform URL compiled into generated Run packages. It must be reachable from the target server.
|
||||
PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com/
|
||||
|
||||
# Platform metadata storage backend:
|
||||
# - file: default, writes platform metadata JSON to PLATFORM_METADATA_PATH.
|
||||
|
||||
@@ -5,6 +5,10 @@ coverage/
|
||||
.tmp
|
||||
.local-debug
|
||||
/run/
|
||||
platform/.platform-data/artifacts/
|
||||
platform/.platform-data/distribution-build-cache/
|
||||
platform/.platform-data/distribution-builds/
|
||||
platform/.platform-data/logs/
|
||||
.idea
|
||||
.claude
|
||||
.github
|
||||
|
||||
@@ -16,6 +16,8 @@ Until the primary first-party workflows are implemented end-to-end and the produ
|
||||
|
||||
Before 1.0, prioritize making the actual user-facing flows work fully. Remove or avoid audit/restriction scaffolding when it blocks, distracts from, or outpaces the core implementation. Reintroduce audit, compliance, or restrictive governance only after an explicit future product decision for a completed flow.
|
||||
|
||||
During pre-1.0 implementation, GPT must not add defensive code, speculative abstractions, fallback shells, feature gates, capability wrappers, compatibility layers, placeholder validators, or future-proof governance merely because a flow is unfinished or uncertain. Implement the concrete user-facing function first, in the owning root, with the minimum validation and error handling needed for that function to work correctly. Do not turn missing product work into extra indirection, policy checks, blocked states, or generic framework code; if a feature needs data or behavior, build that feature path directly.
|
||||
|
||||
## Project Roots
|
||||
|
||||
- `platform/` contains backend platform code.
|
||||
@@ -27,14 +29,6 @@ Before 1.0, prioritize making the actual user-facing flows work fully. Remove or
|
||||
- `scum.npc0.com` is NAT-mapped to the local frontend at `http://127.0.0.1:5173/`.
|
||||
- GPT must ignore the `.local-debug/` directory when searching, inspecting, or modifying repository files.
|
||||
|
||||
## Server Management MCP Notes
|
||||
|
||||
- For remote server work, use the `server-management` MCP path, not raw SSH. Start with `list_devices`, then `test_connection`, then `ssh_exec`; the configured 枣庄 device is named `枣庄服务器` with id `FyBDIohqPhRx7Cia`.
|
||||
- If native MCP tool wrappers return `unsupported call`, do not keep retrying the same wrapper. Read the server-management plugin MCP config and call the local MCP JSON-RPC gateway (`initialize`, `tools/list`, `tools/call`) from a small Node script; never print or commit MCP headers, tokens, host credentials, or raw `.mcp.json` contents.
|
||||
- If local access to the MCP gateway fails with `EPERM` on `127.0.0.1`, request sandbox escalation for that MCP gateway call instead of falling back to raw SSH.
|
||||
- On Windows targets, assume `ssh_exec` may run under `cmd`; for PowerShell diagnostics, send `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <utf16le-base64>` to avoid quoting/glob problems.
|
||||
- For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return only redacted aggregates/fingerprints. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed/redacted envelopes.
|
||||
|
||||
The machine-side run executor lives in the independent repository `git@git.npc0.com:admin343/run.git`; do not re-add a `run/` source tree to this repository.
|
||||
|
||||
Do not place implementation code outside the matching root. Shared contracts must be generated or copied through explicit contract packages, not imported by reaching across ownership boundaries casually.
|
||||
@@ -85,6 +79,77 @@ Frontend and plugin page roots must keep these concerns in fixed directories:
|
||||
|
||||
Do not define business structs inside functions. Do not define request/response structs inside handlers. Do not define database models inside migrations. Do not hide shared frontend types inside page components.
|
||||
|
||||
## Database, Cache, and Hot-Path Rules
|
||||
|
||||
The following rules apply to every backend, plugin, and frontend data-access path. Before adding a database, Redis, or API call, identify the user operation that triggers it, estimate its call count, and keep the hot path bounded and cache-aware.
|
||||
|
||||
### SQL and Query Rules
|
||||
|
||||
- 禁止在热路径使用 `SELECT *`、`LIKE '%keyword%'`、无上限分页、深 `OFFSET`、无索引 `ORDER BY`、大范围 `OR` 扫描。
|
||||
- 列表分页优先使用游标或上次 seen key,不用深页 `OFFSET` 扫全表。
|
||||
- 写代码前先估算一次玩家操作会触发多少 SQL;一次 NPC 点击、一次菜单打开、一次任务刷新不应触发 N+1 查询。
|
||||
- 新增或修改高频查询时,必须在注释、README 或 migration 说明里写出预期索引;复杂查询要能用 `EXPLAIN` 验证不会全表扫。
|
||||
- 不为了“以后可能用”乱加索引;每个索引都会拖慢写入。只给真实查询路径建索引。
|
||||
|
||||
### Partitioning Rules
|
||||
|
||||
- 只有大体量、追加型、可按时间或赛季归档的数据才考虑分区:登录日志、活动贡献日志、奖励发放日志、审计事件、统计快照。
|
||||
- 玩家当前进度、权限、队伍当前状态、小配置表通常不分区,优先靠主键和复合索引解决。
|
||||
- 分区键必须出现在热查询条件里;如果查询不带 `season_id`、`event_id` 或月份,就不要指望分区救性能。
|
||||
- 推荐按 `season_id` 或 `occurred_at` 月份分区;大型活动可按 `event_id` 做逻辑分片或独立表归档。
|
||||
- 分区表必须有保留和归档策略,例如赛季结束 90 天后归档明细,只保留玩家摘要和领奖记录。
|
||||
- 不允许为了临时活动创建永久无限增长表;活动结束必须有归档、压缩或清理计划。
|
||||
- `audit events` 仅在现有 pre-1.0 规则允许、且有明确未来产品决策时才可引入;本节不授权提前添加审计系统。
|
||||
|
||||
### Redis Cache Rules
|
||||
|
||||
- Redis key 必须命名清楚:`npc0:<env>:<module>:<type>:<id>`,例如 `npc0:prod:rpg:party:<party_id>`。
|
||||
- 除明确说明的持久状态外,Redis key 必须设置 TTL;没有 TTL 的缓存 key 视为 bug。
|
||||
- 推荐缓存:服务器在线人数、实例健康、Party 预约、队列、冷却、限流、玩家任务摘要、排行榜快照、NPC 菜单展示数据。
|
||||
- 不推荐只存在 Redis:经济余额、永久称号、任务最终完成、奖励发放记录、封禁记录、玩家背包关键状态。
|
||||
- NPC 点击、菜单打开、scoreboard 刷新优先读本地内存缓存或 Redis 快照;cache miss 才异步查数据库。
|
||||
- 使用 Redis 锁必须带过期时间和唯一 token;释放锁时校验 token,避免误删别人的锁。
|
||||
- 预约 slot、组队进 Pod、副本实例保留必须有 TTL 和幂等键,玩家掉线后可以过期释放。
|
||||
- 禁止在生产热路径使用 `KEYS`;需要扫描只能用 `SCAN`,且放在后台任务或运维工具里。
|
||||
- 多个 Redis 读写要 pipeline 或批量命令;不要对 100 个玩家循环发 100 次小请求。
|
||||
- 防缓存击穿:热门排行榜、服务器列表、活动状态必须有短 TTL、本地缓存和刷新抖动,不让同一秒所有玩家一起打 DB。
|
||||
|
||||
### API and Query-Call Discipline
|
||||
|
||||
- 能一次批量 API 获取的数据,不允许拆成多个 API;能一次 SQL 查出来的数据,不允许在循环里逐条查。
|
||||
- 禁止 N+1:不要先查玩家列表,再对每个玩家查进度、称号、队伍、余额。必须设计批量接口,例如 `loadProgressForPlayers(Collection<UUID>)`。
|
||||
- 同一个事件链里需要的数据必须在入口层集中加载,向下传递数据对象;不要让每个 helper 方法自己偷偷查库。
|
||||
- 命令、NPC 点击、菜单打开必须加冷却或去抖;玩家连续点击不能造成连续 DB/API 请求。
|
||||
- scoreboard、TAB、BossBar、Placeholder 刷新必须低频、缓存化、按变更刷新;不要每 tick 重算字符串和查状态。
|
||||
- 跨服状态汇总要走缓存快照;不要每次 `/servers` 都同步探测所有后端或查数据库。
|
||||
- 排行榜、全服贡献、统计面板按秒级或分钟级刷新,不做实时逐点击重算。
|
||||
- 写入优先批量合并:任务进度、活动贡献、统计计数可以先入队,定时批量 flush;关键奖励必须幂等落库。
|
||||
- 所有重试必须有限次数和退避;禁止无限重试打爆数据库或 Redis。
|
||||
|
||||
### Per-Operation Pressure Budget
|
||||
|
||||
| 操作 | 允许的热路径行为 | 禁止行为 |
|
||||
| --- | --- | --- |
|
||||
| 玩家进服 | 异步批量加载玩家摘要、权限外展示状态、冷却和队伍 | 主线程逐表查询 |
|
||||
| NPC 点击 | 读本地缓存/Redis 快照,必要时异步查一次 | 同步查库、循环查多个系统 |
|
||||
| 打开菜单 | 使用已缓存 DTO 一次性渲染 | 每个按钮单独查 API/SQL |
|
||||
| 任务完成 | 幂等写入队列或事务落库,成功后更新缓存 | 先查后写、多次发奖、不设幂等键 |
|
||||
| 组队进副本 | 一次性读取 Party,reserve slots,整队调度 | 每个队员单独调度导致打散 |
|
||||
| 排行榜 | 读 Redis/内存快照,后台定时刷新 | 玩家每打开一次就聚合全表 |
|
||||
| 每 tick 任务 | 0 DB、0 HTTP、尽量 0 Redis | 遍历全体玩家查远端状态 |
|
||||
| 关服/重载 | 限时 flush 队列,失败写本地安全日志 | 无限等待数据库导致无法停服 |
|
||||
|
||||
### Code-Review Requirements
|
||||
|
||||
- 任何新增 DB/Redis/API 代码,都要在 PR/变更说明里写“调用次数预算”:一次命令、一次点击、一次进服、每分钟后台任务各会打多少次 DB/Redis/API。
|
||||
- 如果一个方法名是 `getXxx`、`hasXxx`、`isXxx`,它不能暗中做远程调用;会远程调用的方法必须在名字或注释里明确,例如 `loadXxxAsync`。
|
||||
- 所有 DAO/Repository 方法默认异步或只允许在异步线程调用;主线程调用必须被明确禁止或断言。
|
||||
- 所有 SQL 使用预编译参数,不拼接玩家输入。
|
||||
- 连接池必须有最大连接数、连接超时、查询超时和慢查询日志;不能无限开连接。
|
||||
- 触及经济、奖励、任务完成、活动贡献的代码必须可重试且幂等;Pod 重启、玩家重连、消息重复不能重复发奖。
|
||||
- 压测前必须用 spark 或日志确认:主线程无阻塞 I/O,DB 慢查询为 0,Redis hit rate 达标,单次玩家操作没有 N+1。
|
||||
- 如果为了赶工无法满足本节要求,宁可不上这个功能,也不要把数据库和服务器一起拖炸。
|
||||
|
||||
## Run and Channel Rules
|
||||
|
||||
The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web.
|
||||
@@ -95,7 +160,9 @@ Platform, plugin, and run lifecycle ownership must stay separated. Run is the li
|
||||
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
|
||||
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
|
||||
|
||||
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state.
|
||||
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never inspect, content-filter, redact, normalize, correlate, or use them to produce player/plugin data. A plugin-owned component may parse its own declared raw stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel.
|
||||
|
||||
SCUM current-service and player evidence must flow through Platform durable jobs, Run typed envelopes, or plugin-owned typed bridge records. Do not download or parse `SCUM.db` on the platform/plugin/browser side, and do not apply game-specific redaction to plugin-declared result fields, player records, stdout/stderr, or declared file-tail payloads.
|
||||
|
||||
Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
PLATFORM_LOG_DIR: /data/platform/logs
|
||||
PLATFORM_ARTIFACT_DIR: /data/platform/artifacts
|
||||
PLATFORM_SECRET_ENVELOPE_KEY: ${PLATFORM_SECRET_ENVELOPE_KEY:-local-compose-secret-envelope-key-change-me}
|
||||
PLATFORM_RUN_RELEASE_URL: ${PLATFORM_RUN_RELEASE_URL:-https://scum.npc0.com/}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
||||
@@ -37,5 +37,5 @@ PLATFORM_BUILDER_SOURCE_REPOSITORY=git@git.npc0.com:admin343/run.git
|
||||
PLATFORM_BUILDER_SOURCE_REVISION=main
|
||||
PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds
|
||||
PLATFORM_BUILDER_TIMEOUT_SECONDS=1800
|
||||
# URL embedded into generated Run and client-manager packages.
|
||||
PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com
|
||||
# URL embedded into generated Run packages. It must be reachable from the target server.
|
||||
PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,6 +29,8 @@ Platform may read plugin manifests, validate lifecycle/action declarations, pack
|
||||
|
||||
Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels.
|
||||
|
||||
Log bodies from supervised stdout/stderr and plugin-declared file tails are opaque verbatim payloads. Platform may validate the transport envelope, sequence, checksum, and source binding, then persist, relay, and display the body unchanged. It must not inspect, parse, filter, redact, normalize, correlate, or derive player, login, user, or other plugin records from a log body. A plugin-owned component owns any game-specific parsing and its typed records arrive through the component boundary, never as a Platform log projection.
|
||||
|
||||
Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields.
|
||||
|
||||
If a lifecycle job requires checking whether game files exist, installing missing files, updating existing files, stopping before update, or building the final launch command, dispatch the plugin-owned action and keep platform limited to authorization, input validation, job creation, and result handling.
|
||||
|
||||
+2
-8
@@ -47,7 +47,7 @@ Runtime configuration:
|
||||
- `PLATFORM_STORAGE_BACKEND`: storage backend, default `file`; use `memory` only for tests or disposable local runs.
|
||||
- `PLATFORM_MYSQL_DSN`: MySQL DSN used when `PLATFORM_STORAGE_BACKEND=mysql`, for example `platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true`.
|
||||
- `PLATFORM_DATA_DIR`: default platform data directory, default `.platform-data`.
|
||||
- `PLATFORM_METADATA_PATH`: file-backed metadata snapshot path, default `.platform-data/metadata.json`.
|
||||
- `PLATFORM_METADATA_PATH`: file-backed metadata snapshot path, default `.platform-data/metadata.json`; high-frequency Run endpoint/session state uses a sibling runtime snapshot to avoid rewriting the full metadata file on every heartbeat.
|
||||
- `PLATFORM_LOG_BODY_BACKEND`: log body backend, default follows metadata backend except MySQL uses `file`; supported values are `file` and `memory`.
|
||||
- `PLATFORM_LOG_DIR`: segmented log body directory, default `.platform-data/logs`.
|
||||
- `PLATFORM_ARTIFACT_DIR`: private durable artifact body/transfer directory, default `.platform-data/artifacts`.
|
||||
@@ -62,7 +62,7 @@ Runtime configuration:
|
||||
- `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `<PLATFORM_DATA_DIR>/distribution-builds`.
|
||||
- `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `<PLATFORM_DATA_DIR>/distribution-build-cache`; it contains no job inputs or component keys.
|
||||
- `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`.
|
||||
- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated components, default `https://scum.npc0.com/`.
|
||||
- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated Run packages, default `https://scum.npc0.com/`. It must be reachable from the target server; loopback and unspecified addresses are rejected.
|
||||
|
||||
Build the dedicated toolchain image before enabling distribution generation:
|
||||
|
||||
@@ -101,10 +101,4 @@ For Docker, the root `docker-compose.yml` sets platform data under `/data/platfo
|
||||
|
||||
Current executable behavior includes the platform API, durable hashed auth/Run sessions with expiry/revocation/rotation, strict production route authorization, durable file-backed metadata, segmented log bodies, authenticated run control/job/log/artifact routes, plugin bridge dispatch, platform-mediated AI invocation, real typed dependency execution orchestration with reviewed plan digests, and target-fenced transactional Run self-update staging/health/rollback projections.
|
||||
|
||||
### Client Manager lifecycle
|
||||
|
||||
Client Manager installations are durable aggregates separate from Run distributions and sessions. Their safe state projection is `requested -> building -> available -> deploying -> installed -> registering -> online`, with `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled` recovery states. Deploy, control, update, rollback, revoke-session, retry, and uninstall are typed jobs; Platform persists intent before dispatch and gates each action by actor/server ownership, plugin profile, binding, endpoint capability, artifact target/revision, key generation, and lifecycle state.
|
||||
|
||||
Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals.
|
||||
|
||||
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-held per-job input. This change uses scoped secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, plugin lifecycle dispatch, and transactional Run self-update are implemented. Production signing/fleet rollout, external provider/storage adapters, and real AI-provider integration remain separate tasks.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -55,6 +56,27 @@ func TestArtifactDownloadAPIWorkflowIsPlatformMediated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactContentWithoutRangeReturnsWholePayload(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
|
||||
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-full", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Full"}, adminSession)
|
||||
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-full", ServerInstanceID: "server-download-full", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-full"})
|
||||
|
||||
payload := bytes.Repeat([]byte("full-download-payload-"), (validator.MaxArtifactDownloadBytes/len("full-download-payload-"))+2)
|
||||
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-full", "job-download-full", payload, validator.MaxArtifactChunkBytes)
|
||||
|
||||
contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-full/content", "", adminSession)
|
||||
assertStatus(t, contentRecorder, http.StatusOK)
|
||||
if got := contentRecorder.Body.Bytes(); !bytes.Equal(got, payload) {
|
||||
t.Fatalf("expected whole payload length %d, got %d", len(payload), len(got))
|
||||
}
|
||||
if contentRecorder.Header().Get("Content-Range") != "" || contentRecorder.Header().Get("Content-Length") != strconv.Itoa(len(payload)) {
|
||||
t.Fatalf("expected full content headers, got %+v", contentRecorder.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactDownloadAPIDeniesUnavailableAndUnauthorizedArtifacts(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -153,7 +175,7 @@ func uploadCompletedArtifact(t *testing.T, router http.Handler, sessionToken str
|
||||
Checksum: validator.BytesChecksum(part),
|
||||
Payload: part,
|
||||
}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", chunk), http.StatusOK)
|
||||
assertStatus(t, performArtifactChunkUpload(t, router, chunk), http.StatusOK)
|
||||
}
|
||||
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: artifactID, Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
|
||||
assertStatus(t, completeRecorder, http.StatusOK)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -23,14 +25,14 @@ func TestArtifactTransferAPIWorkflow(t *testing.T) {
|
||||
t.Fatalf("unexpected open response: %+v", opened)
|
||||
}
|
||||
|
||||
chunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
|
||||
chunkRecorder := performArtifactChunkUpload(t, router, validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
|
||||
assertStatus(t, chunkRecorder, http.StatusOK)
|
||||
chunk := decodeBody[dto.ArtifactChunkUploadResponse](t, chunkRecorder)
|
||||
if !chunk.Accepted || chunk.NextMissingChunkIndex != 1 || len(chunk.ReceivedChunkIndexes) != 1 {
|
||||
t.Fatalf("unexpected chunk response: %+v", chunk)
|
||||
}
|
||||
|
||||
duplicateRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
|
||||
duplicateRecorder := performArtifactChunkUpload(t, router, validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
|
||||
assertStatus(t, duplicateRecorder, http.StatusOK)
|
||||
duplicate := decodeBody[dto.ArtifactChunkUploadResponse](t, duplicateRecorder)
|
||||
if !duplicate.Duplicate {
|
||||
@@ -48,7 +50,7 @@ func TestArtifactTransferAPIWorkflow(t *testing.T) {
|
||||
assertErrorResponse(t, missingComplete, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
for index := 1; index < opened.TotalChunks; index++ {
|
||||
partRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8))
|
||||
partRecorder := performArtifactChunkUpload(t, router, validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8))
|
||||
assertStatus(t, partRecorder, http.StatusOK)
|
||||
}
|
||||
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
|
||||
@@ -68,7 +70,7 @@ func TestArtifactTransferAPIErrors(t *testing.T) {
|
||||
|
||||
badChunk := validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)
|
||||
badChunk.Checksum = validator.BytesChecksum([]byte("different"))
|
||||
badChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", badChunk)
|
||||
badChunkRecorder := performArtifactChunkUpload(t, router, badChunk)
|
||||
assertErrorResponse(t, badChunkRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
invalidSession := validArtifactTransferOpenRequest("stale-token", payload, 8)
|
||||
@@ -108,6 +110,23 @@ func performArtifactTransferOpen(t *testing.T, router http.Handler, request dto.
|
||||
return recorder
|
||||
}
|
||||
|
||||
func performArtifactChunkUpload(t *testing.T, router http.Handler, request dto.ArtifactChunkUploadRequest) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/run/artifacts/chunks", bytes.NewReader(request.Payload))
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Run-Endpoint", request.RunEndpointID)
|
||||
req.Header.Set("X-Run-Session-Token", request.SessionToken)
|
||||
req.Header.Set("X-Artifact-Transfer-Id", request.TransferID)
|
||||
req.Header.Set("X-Artifact-Id", request.ArtifactID)
|
||||
req.Header.Set("X-Artifact-Chunk-Index", strconv.Itoa(request.ChunkIndex))
|
||||
req.Header.Set("X-Artifact-Offset", strconv.FormatInt(request.Offset, 10))
|
||||
req.Header.Set("X-Artifact-Size", strconv.Itoa(request.SizeBytes))
|
||||
req.Header.Set("X-Artifact-Checksum", request.Checksum)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func validArtifactTransferOpenRequest(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
|
||||
return dto.ArtifactTransferOpenRequest{
|
||||
RunEndpointID: "run-local",
|
||||
|
||||
@@ -29,10 +29,7 @@ func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
|
||||
|
||||
func publicAPIRequest(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/v1/game-client-bridge/companion/") {
|
||||
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -43,12 +40,15 @@ func publicAPIRequest(r *http.Request) bool {
|
||||
}
|
||||
|
||||
func runServiceRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.URL.Path, "/api/v1/run/control/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/lifecycle/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/jobs/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/logs/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/artifacts/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/v1/run/metrics/")
|
||||
// Run owns the authenticated machine channels under /run/. Keep the
|
||||
// browser's endpoint-management API on the normal bearer/admin path, but do
|
||||
// not maintain a list of individual Run channel prefixes here. New typed
|
||||
// plugin channels (for example /run/scum/facts) must reach their own
|
||||
// signature/session middleware instead of being rejected by this router.
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
return strings.HasPrefix(path, "/api/v1/run/") &&
|
||||
path != "/api/v1/run/endpoints" &&
|
||||
!strings.HasPrefix(path, "/api/v1/run/endpoints/")
|
||||
}
|
||||
|
||||
func platformAdminRequest(r *http.Request) bool {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/service"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
|
||||
@@ -165,6 +166,71 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedRunArtifactChunkUploadUsesHeaderEnvelope(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create plugin: %v", err)
|
||||
}
|
||||
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-signed-artifact", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Signed Artifact", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := core.CreateJob(domain.Job{ID: "job-signed-artifact", ServerInstanceID: "server-signed-artifact", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed-artifact"}); err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
token := "run-artifact-session-secret"
|
||||
stamp := time.Now().UTC()
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
if err := store.RunControlSessions().Create(domain.RunControlSession{RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true}); err != nil {
|
||||
t.Fatalf("create Run session: %v", err)
|
||||
}
|
||||
payload := []byte("signed raw artifact chunk")
|
||||
opened, err := core.OpenArtifactTransfer(domain.ArtifactTransferOpen{RunEndpointID: "run-local", SessionToken: token, ArtifactID: "artifact-signed-raw", Direction: domain.ArtifactTransferDirectionUpload, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-signed-artifact", SizeBytes: int64(len(payload)), ChunkSizeBytes: len(payload), Checksum: validator.BytesChecksum(payload), IdempotencyKey: "signed-raw-artifact"})
|
||||
if err != nil {
|
||||
t.Fatalf("open transfer: %v", err)
|
||||
}
|
||||
router := NewTestRouterWithCore(core)
|
||||
unsigned := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-unsigned-raw", stamp, false)
|
||||
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
signed := rawArtifactChunkRequest(t, router, token, opened.TransferID, payload, "nonce-signed-raw", stamp, true)
|
||||
assertStatus(t, signed, http.StatusOK)
|
||||
response := decodeBody[dto.ArtifactChunkUploadResponse](t, signed)
|
||||
if !response.Accepted || response.TransferID != opened.TransferID || response.NextMissingChunkIndex != 1 {
|
||||
t.Fatalf("unexpected signed chunk response: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func rawArtifactChunkRequest(t *testing.T, router http.Handler, token string, transferID string, payload []byte, nonce string, stamp time.Time, signed bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
path := "/api/v1/run/artifacts/chunks"
|
||||
timestamp := strconv.FormatInt(stamp.Unix(), 10)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Run-Endpoint", "run-local")
|
||||
req.Header.Set("X-Run-Session-Token", token)
|
||||
req.Header.Set("X-Run-Timestamp", timestamp)
|
||||
req.Header.Set("X-Run-Nonce", nonce)
|
||||
req.Header.Set("X-Artifact-Transfer-Id", transferID)
|
||||
req.Header.Set("X-Artifact-Id", "artifact-signed-raw")
|
||||
req.Header.Set("X-Artifact-Chunk-Index", "0")
|
||||
req.Header.Set("X-Artifact-Offset", "0")
|
||||
req.Header.Set("X-Artifact-Size", strconv.Itoa(len(payload)))
|
||||
req.Header.Set("X-Artifact-Checksum", validator.BytesChecksum(payload))
|
||||
if signed {
|
||||
bodyHash := sha256.Sum256(payload)
|
||||
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(token))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
@@ -197,6 +263,50 @@ func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedRouterAcceptsSignedRunSCUMFactsWithoutBearer(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create plugin: %v", err)
|
||||
}
|
||||
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-facts", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Facts", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
// The browser endpoint-management API under /run/ keeps the normal bearer path.
|
||||
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/run/endpoints", ""), http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
|
||||
observedAt := time.Now().UTC()
|
||||
body, err := json.Marshal(dto.SCUMFactIngestRequest{
|
||||
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-scum-facts",
|
||||
Users: []dto.SCUMUserFactBody{{SteamID: "76561198000000009", DisplayName: "Signed Run", Online: true, Login: true, ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &dto.SCUMPositionBody{X: 1, Y: 2, Z: 3}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal scum facts: %v", err)
|
||||
}
|
||||
signed := signedRunRequest(t, router, "/api/v1/run/scum/facts", body, hello.SessionToken, "nonce-api-scum-facts", time.Now().UTC())
|
||||
assertStatus(t, signed, http.StatusAccepted)
|
||||
|
||||
users, err := store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: "server-scum-facts"})
|
||||
if err != nil {
|
||||
t.Fatalf("list scum users: %v", err)
|
||||
}
|
||||
if len(users) != 1 || users[0].SteamID != "76561198000000009" || !users[0].Online {
|
||||
t.Fatalf("signed Run SCUM facts did not reach the platform tables: %+v", users)
|
||||
}
|
||||
tracks, err := store.SCUMUserTrajectories().List(domain.SCUMUserTrajectoryFilter{ServerInstanceID: "server-scum-facts"})
|
||||
if err != nil {
|
||||
t.Fatalf("list scum trajectories: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected one platform trajectory for the reported position, got %+v", tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
timestamp := strconv.FormatInt(stamp.Unix(), 10)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) {
|
||||
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", artifactOpenForChannelIsolation(hello.SessionToken, payload, 8))
|
||||
assertStatus(t, openRecorder, http.StatusOK)
|
||||
open := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
|
||||
firstChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8))
|
||||
firstChunkRecorder := performArtifactChunkUpload(t, router, artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8))
|
||||
assertStatus(t, firstChunkRecorder, http.StatusOK)
|
||||
firstChunk := decodeBody[dto.ArtifactChunkUploadResponse](t, firstChunkRecorder)
|
||||
if !firstChunk.Accepted || firstChunk.NextMissingChunkIndex != 1 {
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// serverClientManagerLifecycles godoc
|
||||
// @Summary List Client Manager lifecycle installations
|
||||
// @Description Returns safe durable lifecycle, health, real job progress, and action availability for the authorized server without component secrets or machine details.
|
||||
// @Tags client-managers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers [get]
|
||||
func (h *coreHandlers) serverClientManagerLifecycles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
views, err := h.core.ListClientManagerLifecyclesForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewsFromDomain(views))
|
||||
}
|
||||
|
||||
// serverClientManagerLifecycleDetail godoc
|
||||
// @Summary Get one Client Manager lifecycle installation
|
||||
// @Tags client-managers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param profileKey path string true "Client Manager profile key"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/{profileKey} [get]
|
||||
func (h *coreHandlers) serverClientManagerLifecycleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
view, err := h.core.GetClientManagerLifecycleForSession(bearerToken(r), r.PathValue("id"), r.PathValue("profileKey"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerDeploy godoc
|
||||
// @Summary Deploy an available Client Manager distribution
|
||||
// @Description Queues a typed Run deployment after server, endpoint, artifact, target, revision, and key-generation authorization.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerDeployRequest true "Deployment request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/deploy [post]
|
||||
func (h *coreHandlers) serverClientManagerDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerDeployRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.DeployClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerControl godoc
|
||||
// @Summary Control a deployed Client Manager
|
||||
// @Description Queues a declared typed start, stop, restart, status, or rollback operation.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerControlRequest true "Control request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/control [post]
|
||||
func (h *coreHandlers) serverClientManagerControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerControlRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.ControlClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerUpdateLifecycle godoc
|
||||
// @Summary Update a Client Manager through staged activation
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerUpdateRequest true "Approved staged update request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/update [post]
|
||||
func (h *coreHandlers) serverClientManagerUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerUpdateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.UpdateClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerRetry godoc
|
||||
// @Summary Retry a failed Client Manager lifecycle intent
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerRetryRequest true "Retry request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/retry [post]
|
||||
func (h *coreHandlers) serverClientManagerRetry(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRetryRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.RetryClientManagerLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerRevokeSession godoc
|
||||
// @Summary Revoke the active Client Manager component session
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerRevokeSessionRequest true "Session revoke request"
|
||||
// @Success 200 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/revoke-session [post]
|
||||
func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRevokeSessionRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.RevokeClientManagerSessionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// serverClientManagerUninstall godoc
|
||||
// @Summary Safely uninstall a Client Manager
|
||||
// @Description Stops the supervised process and removes the Client Manager installation workspace while retaining build and distribution records.
|
||||
// @Tags client-managers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ClientManagerUninstallRequest true "Confirmed uninstall request"
|
||||
// @Success 202 {object} dto.ClientManagerInstallationResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/client-managers/uninstall [post]
|
||||
func (h *coreHandlers) serverClientManagerUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerUninstallRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := h.core.UninstallClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
|
||||
}
|
||||
|
||||
// runClientManagerLifecycleInput godoc
|
||||
// @Summary Get fenced Client Manager lifecycle input
|
||||
// @Description Returns a safe typed lifecycle contract only to the authenticated Run endpoint holding the active job lease.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerLifecycleInputRequest true "Fenced lifecycle input request"
|
||||
// @Success 200 {object} dto.ClientManagerLifecycleInputResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/client-manager-input [post]
|
||||
func (h *coreHandlers) runClientManagerLifecycleInput(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerLifecycleInputRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
input, err := h.core.GetClientManagerLifecycleInput(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleInputFromDomain(input))
|
||||
}
|
||||
|
||||
// runClientManagerLifecycleChunk godoc
|
||||
// @Summary Read one fenced Client Manager artifact chunk
|
||||
// @Description Streams a checksummed artifact chunk only to the active typed lifecycle job lease.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.RunUpdateChunkRequest true "Fenced chunk request"
|
||||
// @Success 200 {object} dto.RunUpdateChunkResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/client-manager-chunk [post]
|
||||
func (h *coreHandlers) runClientManagerLifecycleChunk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.RunUpdateChunkRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
chunk, err := h.core.ReadClientManagerLifecycleChunk(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(chunk))
|
||||
}
|
||||
|
||||
// clientManagerRegister godoc
|
||||
// @Summary Register an installed Client Manager component
|
||||
// @Description Verifies a current component-key HMAC, nonce, deployment fence, target, revision, and capabilities before issuing an isolated expiring component session.
|
||||
// @Tags client-manager-component
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerRegisterRequest true "Signed component registration"
|
||||
// @Success 200 {object} dto.ClientManagerRegisterResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/client-managers/register [post]
|
||||
func (h *coreHandlers) clientManagerRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerRegisterRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RegisterClientManager(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerRegisterFromDomain(result))
|
||||
}
|
||||
|
||||
// clientManagerHeartbeat godoc
|
||||
// @Summary Accept a Client Manager component heartbeat
|
||||
// @Description Accepts monotonic health reports using the isolated component session; Run control credentials are not valid here.
|
||||
// @Tags client-manager-component
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.ClientManagerHeartbeatRequest true "Component heartbeat"
|
||||
// @Success 200 {object} dto.ClientManagerHeartbeatResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/client-managers/heartbeat [post]
|
||||
func (h *coreHandlers) clientManagerHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerHeartbeatRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.AcceptClientManagerHeartbeat(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ClientManagerHeartbeatFromDomain(result))
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeClaimRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
commands, err := h.core.ClaimGameClientBridgeCommands(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeClaimResponseFromDomain(commands))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionAck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeAckRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.AckGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeAckFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeResultRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.CompleteGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeResultFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) gameClientBridgeCompanionDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if request.Type != "companion.health" && request.Type != "bridge.diagnostics" {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", []string{"diagnostic snapshot type is invalid"})
|
||||
return
|
||||
}
|
||||
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
|
||||
}
|
||||
@@ -12,32 +12,6 @@ import (
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
type gameClientBridgeCompanionCore struct {
|
||||
service.Core
|
||||
command domain.GameClientBridgeCommand
|
||||
snapshot domain.GameClientBridgeSnapshot
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
return []domain.GameClientBridgeCommand{domain.CopyGameClientBridgeCommand(core.command)}, nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
return domain.CopyGameClientBridgeCommand(core.command), nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
value := domain.CopyGameClientBridgeCommand(core.command)
|
||||
value.State = domain.GameClientBridgeCommandSucceeded
|
||||
value.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "done", CompletedBy: "internal-session", CompletedAt: time.Now().UTC()}
|
||||
value.CompletedAt = value.Result.CompletedAt
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
coreService := service.NewCoreService(store)
|
||||
@@ -46,15 +20,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}
|
||||
if _, err := coreService.CreateGamePlugin(plugin); err != nil {
|
||||
t.Fatalf("create bridge plugin: %v", err)
|
||||
}
|
||||
endpoint := validRunEndpointRequest().ToDomain()
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
|
||||
t.Fatalf("create run endpoint: %v", err)
|
||||
}
|
||||
@@ -65,14 +38,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
status := getJSONWithAuth[dto.GameClientBridgeStatusResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge", adminSession)
|
||||
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || status.Available || status.Profiles == nil {
|
||||
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || !status.Available || status.Profiles == nil {
|
||||
t.Fatalf("unexpected bridge status: %#v", status)
|
||||
}
|
||||
if len(status.Profiles) != 1 || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if len(status.Profiles) != 1 || status.Profiles[0].ProfileKey != "plugin-owned" || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("bridge status did not safely expose query template availability: %#v", status)
|
||||
}
|
||||
|
||||
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
|
||||
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "plugin-owned", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)}
|
||||
queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession)
|
||||
assertStatus(t, queuedRecorder, http.StatusAccepted)
|
||||
queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder)
|
||||
@@ -112,40 +85,3 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
core := &gameClientBridgeCompanionCore{
|
||||
Core: service.NewCoreService(repo.NewMemoryStore()),
|
||||
command: domain.GameClientBridgeCommand{ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", FencingToken: 9, ClaimedAt: now, LeaseExpiresAt: now.Add(time.Minute)}, ExpiresAt: now.Add(time.Hour)},
|
||||
snapshot: domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, SourceSessionID: "internal-source-session", CreatedAt: now, ExpiresAt: now.Add(time.Hour)},
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
|
||||
claim := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/claim", dto.GameClientBridgeClaimRequest{SessionToken: "component-token", Limit: 5})
|
||||
assertStatus(t, claim, http.StatusOK)
|
||||
if strings.Contains(claim.Body.String(), "internal-session-secret") || strings.Contains(claim.Body.String(), "internal-installation") {
|
||||
t.Fatalf("claim response leaked component identity: %s", claim.Body.String())
|
||||
}
|
||||
claimed := decodeBody[dto.GameClientBridgeClaimResponse](t, claim)
|
||||
if claimed.Count != 1 || claimed.Items[0].FencingToken != 9 {
|
||||
t.Fatalf("unexpected claim response: %#v", claimed)
|
||||
}
|
||||
|
||||
ack := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/ack", dto.GameClientBridgeAckRequest{SessionToken: "component-token", FencingToken: 9})
|
||||
assertStatus(t, ack, http.StatusOK)
|
||||
result := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/result", dto.GameClientBridgeResultRequest{SessionToken: "component-token", FencingToken: 9, Status: "succeeded", Summary: "done"})
|
||||
assertStatus(t, result, http.StatusOK)
|
||||
if strings.Contains(result.Body.String(), "internal-session") {
|
||||
t.Fatalf("result response leaked completing session: %s", result.Body.String())
|
||||
}
|
||||
|
||||
snapshotRequest := dto.GameClientBridgeSnapshotIngestRequest{SessionToken: "component-token", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"healthy": true}, KeepForSeconds: 3600}
|
||||
snapshot := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/snapshots", snapshotRequest)
|
||||
assertStatus(t, snapshot, http.StatusAccepted)
|
||||
if strings.Contains(snapshot.Body.String(), "internal-source-session") || strings.Contains(snapshot.Body.String(), "component-token") {
|
||||
t.Fatalf("snapshot response leaked component material: %s", snapshot.Body.String())
|
||||
}
|
||||
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
|
||||
assertStatus(t, diagnostic, http.StatusAccepted)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -12,10 +13,10 @@ import (
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
type componentLogServerContextKey struct{}
|
||||
|
||||
const (
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
liveLogSourceClockSkew = 90 * time.Second
|
||||
managedLogSessionIDPrefix = "log-session:"
|
||||
)
|
||||
|
||||
// serverLogEvents streams platform-accepted live append events for the terminal drawer.
|
||||
@@ -24,8 +25,8 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
|
||||
jobID := strings.TrimSpace(r.URL.Query().Get("jobId"))
|
||||
instance, streams, subscription, err := h.openLogEventSubscription(r, jobID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -45,7 +46,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
active := supervisedLogSession{}
|
||||
if liveEligible {
|
||||
if jobID != "" {
|
||||
active = activeJobLogSession(jobID, streams)
|
||||
} else if isComponentLogRequest(r) {
|
||||
active = activeComponentLogSession(streams)
|
||||
} else {
|
||||
active = activeSupervisedLogSession(streams)
|
||||
}
|
||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
||||
@@ -70,11 +75,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
|
||||
if jobID != "" {
|
||||
continue
|
||||
}
|
||||
if isComponentLogRequest(r) {
|
||||
continue
|
||||
}
|
||||
if subscriptionEvent.ServerInstanceID != instance.ID {
|
||||
continue
|
||||
}
|
||||
if subscriptionEvent.ProcessState != domain.ServerInstanceStateRunning {
|
||||
liveEligible = false
|
||||
if active.sessionID == "" {
|
||||
continue
|
||||
}
|
||||
@@ -86,14 +96,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
next := supervisedLogSession{}
|
||||
if liveEligible {
|
||||
next = activeSupervisedLogSession(streams)
|
||||
}
|
||||
next := activeSupervisedLogSession(streams)
|
||||
if sameSupervisedLogSession(active, next) {
|
||||
continue
|
||||
}
|
||||
@@ -105,37 +112,46 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog || !liveEligible {
|
||||
if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog {
|
||||
continue
|
||||
}
|
||||
event := subscriptionEvent.LogEvent
|
||||
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
|
||||
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
next := supervisedLogSession{}
|
||||
if liveEligible {
|
||||
next = activeSupervisedLogSession(streams)
|
||||
if jobID != "" {
|
||||
if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||
continue
|
||||
}
|
||||
candidate = activeJobLogSession(jobID, []domain.LogStream{event.Stream})
|
||||
}
|
||||
if active.allStreams {
|
||||
candidate = supervisedLogSession{}
|
||||
}
|
||||
if jobID == "" && candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||
next := candidate
|
||||
if !sameSupervisedLogSession(active, next) {
|
||||
active = next
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if event.Entry.Seq > 0 && emittedThrough[event.Stream.ID] >= event.Entry.Seq {
|
||||
emittedThrough[event.Stream.ID] = event.Entry.Seq - 1
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if !active.contains(event.Stream) {
|
||||
continue
|
||||
}
|
||||
if !sourceLogEntryIsLive(event.Entry, liveBoundary) {
|
||||
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
|
||||
emittedThrough[event.Stream.ID] = event.Entry.Seq
|
||||
if jobID == "" {
|
||||
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
||||
continue
|
||||
}
|
||||
} else if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||
continue
|
||||
}
|
||||
active.streams = append(active.streams, event.Stream)
|
||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !active.hasStream(event.Stream.ID) {
|
||||
active.streams = append(active.streams, event.Stream)
|
||||
@@ -160,14 +176,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLogEntryIsLive(entry domain.LogEntry, liveBoundary time.Time) bool {
|
||||
return !entry.Timestamp.IsZero() && !entry.Timestamp.Before(liveBoundary)
|
||||
type supervisedLogSession struct {
|
||||
jobID string
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
streams []domain.LogStream
|
||||
allStreams bool
|
||||
}
|
||||
|
||||
type supervisedLogSession struct {
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
streams []domain.LogStream
|
||||
func activeComponentLogSession(streams []domain.LogStream) supervisedLogSession {
|
||||
return supervisedLogSession{sessionID: "component", streams: append([]domain.LogStream(nil), streams...), allStreams: true}
|
||||
}
|
||||
|
||||
func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession {
|
||||
@@ -192,6 +210,11 @@ func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession
|
||||
return active
|
||||
}
|
||||
|
||||
func activeJobLogSession(jobID string, streams []domain.LogStream) supervisedLogSession {
|
||||
active := supervisedLogSession{jobID: jobID, sessionID: "job:" + jobID, streams: append([]domain.LogStream(nil), streams...)}
|
||||
return active
|
||||
}
|
||||
|
||||
func newerLogSession(candidate supervisedLogSession, current supervisedLogSession) bool {
|
||||
if candidate.sessionID == "" || candidate.sessionID == current.sessionID {
|
||||
return false
|
||||
@@ -206,10 +229,16 @@ func newerLogSession(candidate supervisedLogSession, current supervisedLogSessio
|
||||
}
|
||||
|
||||
func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSession) bool {
|
||||
return left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
||||
return left.jobID == right.jobID && left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
||||
}
|
||||
|
||||
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
|
||||
if session.jobID != "" {
|
||||
return strings.HasPrefix(stream.ID, "job."+session.jobID+".") && session.hasStream(stream.ID)
|
||||
}
|
||||
if session.allStreams {
|
||||
return session.hasStream(stream.ID)
|
||||
}
|
||||
return session.sessionID != "" && stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == session.sessionID && stream.SessionStartedAt.Equal(session.startedAt)
|
||||
}
|
||||
|
||||
@@ -236,71 +265,113 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
||||
return emittedThrough, nil
|
||||
}
|
||||
|
||||
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, bool, service.LogEventSubscription, error) {
|
||||
func (h *coreHandlers) openLogEventSubscription(r *http.Request, jobID string) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
||||
var instance domain.ServerInstance
|
||||
var streams []domain.LogStream
|
||||
var liveEligible bool
|
||||
var subscription service.LogEventSubscription
|
||||
var err error
|
||||
if h.enforceAuthorization {
|
||||
if serverInstanceID, ok := r.Context().Value(componentLogServerContextKey{}).(string); ok && strings.TrimSpace(serverInstanceID) != "" {
|
||||
instance, err = h.core.GetServerInstance(serverInstanceID)
|
||||
if err == nil {
|
||||
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
||||
}
|
||||
} else if h.enforceAuthorization {
|
||||
sessionID := bearerToken(r)
|
||||
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, nil, false, subscription, err
|
||||
return domain.ServerInstance{}, nil, subscription, err
|
||||
}
|
||||
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, nil, false, subscription, err
|
||||
return domain.ServerInstance{}, nil, subscription, err
|
||||
}
|
||||
} else {
|
||||
instance, err = h.core.GetServerInstance(r.PathValue("id"))
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, nil, false, subscription, err
|
||||
return domain.ServerInstance{}, nil, subscription, err
|
||||
}
|
||||
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, nil, false, subscription, err
|
||||
return domain.ServerInstance{}, nil, subscription, err
|
||||
}
|
||||
}
|
||||
if err == nil && jobID != "" {
|
||||
if h.enforceAuthorization && isComponentLogRequest(r) {
|
||||
err = fmt.Errorf("job log streams require session authorization")
|
||||
} else {
|
||||
var job domain.Job
|
||||
if h.enforceAuthorization {
|
||||
job, err = h.core.GetJobForSession(bearerToken(r), jobID)
|
||||
} else {
|
||||
job, err = h.core.GetJob(jobID)
|
||||
}
|
||||
if err == nil && job.ServerInstanceID != instance.ID {
|
||||
err = service.ErrForbidden
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
|
||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||
}
|
||||
if err != nil && subscription.Close != nil {
|
||||
subscription.Close()
|
||||
}
|
||||
return instance, streams, liveEligible, subscription, err
|
||||
return instance, streams, subscription, err
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.LogStream, bool, error) {
|
||||
func withComponentLogServer(r *http.Request, serverInstanceID string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), componentLogServerContextKey{}, serverInstanceID))
|
||||
}
|
||||
|
||||
func isComponentLogRequest(r *http.Request) bool {
|
||||
_, ok := r.Context().Value(componentLogServerContextKey{}).(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool, jobID string) ([]domain.LogStream, error) {
|
||||
instance, err := h.core.GetServerInstance(serverInstanceID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, err
|
||||
}
|
||||
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||
return nil, false, nil
|
||||
}
|
||||
logSessionID := strings.TrimPrefix(instance.LifecycleProcessID, managedLogSessionIDPrefix)
|
||||
if logSessionID == instance.LifecycleProcessID || strings.TrimSpace(logSessionID) == "" {
|
||||
return nil, false, nil
|
||||
if jobID == "" {
|
||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !includeDeclaredStreams && instance.State != domain.ServerInstanceStateRunning {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, err
|
||||
}
|
||||
current := make([]domain.LogStream, 0, len(streams))
|
||||
for _, stream := range streams {
|
||||
if stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == logSessionID {
|
||||
if jobID != "" {
|
||||
if strings.HasPrefix(stream.ID, "job."+jobID+".") {
|
||||
current = append(current, stream)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if includeDeclaredStreams {
|
||||
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
||||
continue
|
||||
}
|
||||
current = append(current, stream)
|
||||
continue
|
||||
}
|
||||
if stream.Source == domain.LogStreamSourceProcess && strings.TrimSpace(stream.LogSessionID) != "" && !stream.SessionStartedAt.IsZero() {
|
||||
current = append(current, stream)
|
||||
}
|
||||
}
|
||||
return current, true, nil
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "log", `"seq":2`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
|
||||
func TestLogEventsSSEStreamsBufferedAppendAfterOpen(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
||||
@@ -180,14 +180,65 @@ func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
|
||||
|
||||
stale := validLogBatchRequest(t, hello.SessionToken, 2, 2)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", stale), http.StatusOK)
|
||||
buffered := validLogBatchRequest(t, hello.SessionToken, 2, 2)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", buffered), http.StatusOK)
|
||||
fresh := validLogBatchRequest(t, hello.SessionToken, 3, 3)
|
||||
retimestampLogBatchRequest(t, &fresh, time.Now().UTC().Add(time.Second))
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", fresh), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"seq":2`)
|
||||
assertSSEEvent(t, reader, "log", `"seq":3`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEStreamsScopedJobLogsWhenServerIsStopped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
job := postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||
ID: "job-scoped-log", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-scoped-log",
|
||||
})
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{
|
||||
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded,
|
||||
Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"},
|
||||
}), http.StatusOK)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?jobId="+job.ID, nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
router.ServeHTTP(streamWriter, request)
|
||||
_ = streamWriter.Close()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
_ = streamReader.Close()
|
||||
<-done
|
||||
})
|
||||
if status := <-streamWriter.status; status != http.StatusOK {
|
||||
t.Fatalf("unexpected scoped job SSE status: %d", status)
|
||||
}
|
||||
reader := bufio.NewReader(streamReader)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"job:`+job.ID+`"`)
|
||||
streamOne, streamOneData := readSSEEvent(t, reader)
|
||||
streamTwo, streamTwoData := readSSEEvent(t, reader)
|
||||
if streamOne != "stream" || streamTwo != "stream" || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stdout"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stdout"`) || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stderr"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stderr"`) {
|
||||
t.Fatalf("unexpected scoped job stream snapshot: %q %s; %q %s", streamOne, streamOneData, streamTwo, streamTwoData)
|
||||
}
|
||||
assertSSEEvent(t, reader, "ready", `"streamCount":2`)
|
||||
|
||||
batch := validLogBatchRequestForStream(t, hello.SessionToken, "job."+job.ID+".stdout", "stdout", 1, 1, 0)
|
||||
batch.LogSessionID = ""
|
||||
batch.SessionStartedAt = time.Time{}
|
||||
batch.Entries[0].Line = "JOB-SCOPED-LIVE-OUTPUT"
|
||||
checksum, err := validator.LogEntriesChecksum([]domain.LogEntry{{Seq: batch.Entries[0].Seq, Timestamp: batch.Entries[0].Timestamp, Level: batch.Entries[0].Level, Line: batch.Entries[0].Line}})
|
||||
if err != nil {
|
||||
t.Fatalf("checksum scoped job log: %v", err)
|
||||
}
|
||||
batch.Checksum = checksum
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"line":"JOB-SCOPED-LIVE-OUTPUT"`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -206,7 +257,7 @@ func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.T) {
|
||||
func TestLogEventsSSEInitialSnapshotUsesRunStreamEnvelopeWhenOnline(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
|
||||
@@ -221,7 +272,9 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.
|
||||
running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK)
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||
if body := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events").Body.String(); !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"logSessionId":"session-missing"`) {
|
||||
t.Fatalf("expected initial session to come from Run log stream envelope, body=%s", body)
|
||||
}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK)
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||
}
|
||||
@@ -265,14 +318,15 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
||||
next.LogSessionID = "session-next"
|
||||
next.SessionStartedAt = nextStartedAt
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
|
||||
|
||||
statusReport.ManagedProcessID = "log-session:session-next"
|
||||
statusReport.ObservationSeq = 1
|
||||
statusReport.ObservedAt = nextStartedAt
|
||||
statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21)
|
||||
nextLive.LogSessionID = "session-next"
|
||||
nextLive.SessionStartedAt = nextStartedAt
|
||||
@@ -326,11 +380,12 @@ func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *
|
||||
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next session stdout: %v", err)
|
||||
}
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
|
||||
if _, err := core.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-next", ObservationSeq: 1, ObservedAt: nextStartedAt, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}); err != nil {
|
||||
t.Fatalf("report next managed process: %v", err)
|
||||
}
|
||||
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
|
||||
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
|
||||
|
||||
stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21)
|
||||
stderr.LogSessionID = "session-next"
|
||||
@@ -497,7 +552,7 @@ func retimestampLogBatchRequest(t *testing.T, request *dto.LogBatchIngestRequest
|
||||
domainEntries := make([]domain.LogEntry, 0, len(request.Entries))
|
||||
for index := range request.Entries {
|
||||
request.Entries[index].Timestamp = first.Add(time.Duration(index) * time.Millisecond).UTC()
|
||||
domainEntries = append(domainEntries, domain.LogEntry{Seq: request.Entries[index].Seq, Timestamp: request.Entries[index].Timestamp, Level: request.Entries[index].Level, Line: request.Entries[index].Line, Fields: request.Entries[index].Fields, Redacted: request.Entries[index].Redacted})
|
||||
domainEntries = append(domainEntries, domain.LogEntry{Seq: request.Entries[index].Seq, Timestamp: request.Entries[index].Timestamp, Level: request.Entries[index].Level, Line: request.Entries[index].Line, Fields: request.Entries[index].Fields})
|
||||
}
|
||||
checksum, err := validator.LogEntriesChecksum(domainEntries)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -53,8 +54,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
||||
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
||||
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||
@@ -63,6 +62,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/restart", h.serverInstanceRestart)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/update", h.serverInstanceUpdate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
||||
@@ -74,22 +75,17 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers", h.serverClientManagerLifecycles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/{profileKey}", h.serverClientManagerLifecycleDetail)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/deploy", h.serverClientManagerDeploy)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/control", h.serverClientManagerControl)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/update", h.serverClientManagerUpdateLifecycle)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge", h.serverGameClientBridgeStatus)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands", h.serverGameClientBridgeCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/users", h.serverSCUMUsers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/user-trajectories", h.serverSCUMUserTrajectories)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-trajectories", h.serverSCUMVehicleTrajectories)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-locks", h.serverSCUMVehicleLocks)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/surface", h.serverSCUMSurface)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}", h.serverPluginDataCollection)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
||||
@@ -114,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
|
||||
mux.HandleFunc("/api/v1/run/control/events", h.requireRunSignature(h.runControlEvents))
|
||||
mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport))
|
||||
mux.HandleFunc("/api/v1/run/scum/facts", h.requireRunSignature(h.runSCUMFacts))
|
||||
mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim))
|
||||
mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck))
|
||||
mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress))
|
||||
@@ -125,8 +122,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
|
||||
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
|
||||
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
|
||||
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
|
||||
@@ -148,13 +143,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/log-streams", h.logStreams)
|
||||
mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery)
|
||||
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
|
||||
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
|
||||
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/ack", h.gameClientBridgeCompanionAck)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/result", h.gameClientBridgeCompanionResult)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
|
||||
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
|
||||
}
|
||||
|
||||
// pluginLifecycles godoc
|
||||
@@ -211,60 +199,6 @@ func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||
}
|
||||
|
||||
// aiConfigDiffs godoc
|
||||
// @Summary List reviewable AI config diffs
|
||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||
// @Tags plugin-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs [get]
|
||||
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
||||
}
|
||||
|
||||
// aiConfigDiffApprove godoc
|
||||
// @Summary Approve one reviewable AI config diff
|
||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||
// @Tags plugin-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "AI config diff ID"
|
||||
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
||||
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
||||
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
||||
}
|
||||
|
||||
// authRegister godoc
|
||||
// @Summary Register a platform account
|
||||
// @Description Creates a pending low-privilege platform account without granting platform administrator rights.
|
||||
@@ -875,7 +809,7 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{
|
||||
RequestID: "config-suggestion:" + request.ServerInstanceID,
|
||||
RequestID: fmt.Sprintf("config-suggestion:%s:%d", request.ServerInstanceID, time.Now().UnixNano()),
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
Purpose: "config.suggest",
|
||||
Prompt: request.Prompt,
|
||||
@@ -898,7 +832,11 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
||||
if response.ConfigRecommendation != nil {
|
||||
suggested = response.ConfigRecommendation.SuggestedConfig
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested})
|
||||
var execution *dto.AIConfigExecutionResponse
|
||||
if response.ConfigExecution != nil {
|
||||
execution = &dto.AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: dto.JobFromDomain(response.ConfigExecution.Job)}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested, ConfigExecution: execution})
|
||||
}
|
||||
|
||||
// gamePlugins godoc
|
||||
@@ -932,6 +870,10 @@ func (h *coreHandlers) gamePlugins(w http.ResponseWriter, r *http.Request) {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if violations := request.RuntimeProfiles.UnsupportedLegacyProfileViolations("runtimeProfiles"); len(violations) > 0 {
|
||||
writeServiceError(w, validator.ValidationError{Violations: violations})
|
||||
return
|
||||
}
|
||||
plugin, err := h.core.CreateGamePlugin(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
@@ -964,6 +906,10 @@ func (h *coreHandlers) gamePluginManifestRegistration(w http.ResponseWriter, r *
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if violations := request.Manifest.RuntimeProfiles.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles"); len(violations) > 0 {
|
||||
writeServiceError(w, validator.ValidationError{Violations: violations})
|
||||
return
|
||||
}
|
||||
plugin, err := h.core.RegisterGamePluginManifest(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
@@ -1692,6 +1638,9 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.core.ReconcileSCUMQueryTemplatesForRunEndpoint(request.RunEndpointID); err != nil {
|
||||
log.Printf("SCUM query template dispatch skipped run_endpoint=%s error=%s", request.RunEndpointID, err.Error())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result))
|
||||
}
|
||||
|
||||
@@ -2207,9 +2156,15 @@ func (h *coreHandlers) runArtifactOpen(w http.ResponseWriter, r *http.Request) {
|
||||
// @Summary Upload run artifact chunk
|
||||
// @Description Accepts one bounded artifact chunk from a registered run endpoint and returns resumable acknowledgement state.
|
||||
// @Tags run-artifacts
|
||||
// @Accept json
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Param body body dto.ArtifactChunkUploadRequest true "Artifact chunk upload request"
|
||||
// @Param X-Artifact-Transfer-Id header string true "Artifact transfer ID"
|
||||
// @Param X-Artifact-Id header string true "Artifact ID"
|
||||
// @Param X-Artifact-Chunk-Index header int true "Chunk index"
|
||||
// @Param X-Artifact-Offset header int true "Byte offset"
|
||||
// @Param X-Artifact-Size header int true "Chunk size"
|
||||
// @Param X-Artifact-Checksum header string true "Chunk sha256 checksum"
|
||||
// @Param body body file true "Artifact chunk bytes"
|
||||
// @Success 200 {object} dto.ArtifactChunkUploadResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
@@ -2220,9 +2175,9 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ArtifactChunkUploadRequest](r)
|
||||
request, err := decodeArtifactChunkUploadRequest(r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.UploadArtifactChunk(request.ToDomain())
|
||||
@@ -2233,6 +2188,78 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
|
||||
writeJSON(w, http.StatusOK, dto.ArtifactChunkUploadFromDomain(result))
|
||||
}
|
||||
|
||||
const (
|
||||
artifactTransferIDHeader = "X-Artifact-Transfer-Id"
|
||||
artifactIDHeader = "X-Artifact-Id"
|
||||
artifactChunkIndexHeader = "X-Artifact-Chunk-Index"
|
||||
artifactChunkOffsetHeader = "X-Artifact-Offset"
|
||||
artifactChunkSizeHeader = "X-Artifact-Size"
|
||||
artifactChunkHashHeader = "X-Artifact-Checksum"
|
||||
)
|
||||
|
||||
func decodeArtifactChunkUploadRequest(r *http.Request) (dto.ArtifactChunkUploadRequest, error) {
|
||||
if !isOctetStream(r.Header.Get("Content-Type")) {
|
||||
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"contentType must be application/octet-stream"}}
|
||||
}
|
||||
chunkIndex, err := parseRequiredIntHeader(r, artifactChunkIndexHeader)
|
||||
if err != nil {
|
||||
return dto.ArtifactChunkUploadRequest{}, err
|
||||
}
|
||||
offset, err := parseRequiredInt64Header(r, artifactChunkOffsetHeader)
|
||||
if err != nil {
|
||||
return dto.ArtifactChunkUploadRequest{}, err
|
||||
}
|
||||
sizeBytes, err := parseRequiredIntHeader(r, artifactChunkSizeHeader)
|
||||
if err != nil {
|
||||
return dto.ArtifactChunkUploadRequest{}, err
|
||||
}
|
||||
if sizeBytes <= 0 || sizeBytes > validator.MaxArtifactChunkBytes {
|
||||
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{fmt.Sprintf("%s must be between 1 and %d", artifactChunkSizeHeader, validator.MaxArtifactChunkBytes)}}
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(r.Body, int64(validator.MaxArtifactChunkBytes)+1))
|
||||
if err != nil {
|
||||
return dto.ArtifactChunkUploadRequest{}, err
|
||||
}
|
||||
if len(payload) != sizeBytes {
|
||||
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"request body size must match artifact chunk size"}}
|
||||
}
|
||||
return dto.ArtifactChunkUploadRequest{
|
||||
RunEndpointID: strings.TrimSpace(r.Header.Get(runEndpointHeader)),
|
||||
SessionToken: strings.TrimSpace(r.Header.Get(runSessionTokenHeader)),
|
||||
TransferID: strings.TrimSpace(r.Header.Get(artifactTransferIDHeader)),
|
||||
ArtifactID: strings.TrimSpace(r.Header.Get(artifactIDHeader)),
|
||||
ChunkIndex: chunkIndex,
|
||||
Offset: offset,
|
||||
SizeBytes: sizeBytes,
|
||||
Checksum: strings.TrimSpace(r.Header.Get(artifactChunkHashHeader)),
|
||||
Payload: payload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseRequiredIntHeader(r *http.Request, name string) (int, error) {
|
||||
value := strings.TrimSpace(r.Header.Get(name))
|
||||
if value == "" {
|
||||
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseRequiredInt64Header(r *http.Request, name string) (int64, error) {
|
||||
value := strings.TrimSpace(r.Header.Get(name))
|
||||
if value == "" {
|
||||
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// runArtifactStatus godoc
|
||||
// @Summary Query run artifact upload status
|
||||
// @Description Returns resumable chunk acknowledgement state for one artifact transfer.
|
||||
@@ -2371,13 +2398,19 @@ func (h *coreHandlers) runEndpointDetail(w http.ResponseWriter, r *http.Request)
|
||||
func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid job limit", nil)
|
||||
return
|
||||
}
|
||||
filter := domain.JobFilter{
|
||||
ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
|
||||
RunEndpointID: r.URL.Query().Get("runEndpointId"),
|
||||
State: domain.JobState(r.URL.Query().Get("state")),
|
||||
States: parseJobStates(r.URL.Query().Get("states")),
|
||||
Limit: limit,
|
||||
}
|
||||
var jobs []domain.Job
|
||||
var err error
|
||||
if h.enforceAuthorization {
|
||||
jobs, err = h.core.ListJobsForSession(bearerToken(r), filter)
|
||||
} else {
|
||||
@@ -2405,6 +2438,20 @@ func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseJobStates(raw string) []domain.JobState {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
states := []domain.JobState{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
state := domain.JobState(strings.TrimSpace(part))
|
||||
if state != "" {
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// jobCancel godoc
|
||||
// @Summary Request job cancellation
|
||||
// @Description Records a cancellation request for an accepted or running job; run observes it through the job cancel poll route.
|
||||
@@ -2596,11 +2643,13 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
content, err := h.core.ReadArtifactContentForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
|
||||
stream, err := h.core.OpenArtifactContentStreamForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
content := stream.Content
|
||||
w.Header().Set("Content-Type", content.ContentType)
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(content.Filename, "\"", "")+"\"")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
@@ -2616,7 +2665,7 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
_, _ = w.Write(content.Payload)
|
||||
_, _ = io.Copy(w, stream.Body)
|
||||
}
|
||||
|
||||
func artifactRangeRequest(r *http.Request) (int64, int, error) {
|
||||
|
||||
@@ -86,6 +86,12 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
||||
getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1")
|
||||
jobs := getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued")
|
||||
assertListCount(t, jobs.Count, 1)
|
||||
jobs = getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&states=queued,running,failed")
|
||||
assertListCount(t, jobs.Count, 1)
|
||||
jobs = getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&states=queued,running,failed&limit=1")
|
||||
assertListCount(t, jobs.Count, 1)
|
||||
jobs = getJSON[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-1&states=running,failed")
|
||||
assertListCount(t, jobs.Count, 0)
|
||||
|
||||
artifactResponse := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{
|
||||
ID: "artifact-1",
|
||||
@@ -129,6 +135,24 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestCoreAPIGamePluginCreateRejectsLegacyServerDeployments(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
request := validGamePluginRequest()
|
||||
request.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
|
||||
|
||||
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins", request)
|
||||
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
func TestCoreAPIGamePluginManifestRejectsLegacyServerDeployments(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.RuntimeProfiles.ServerDeployments = json.RawMessage(`[{"key":"legacy"}]`)
|
||||
|
||||
response := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
|
||||
assertErrorResponse(t, response, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -320,11 +344,14 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
for _, action := range actions.Actions {
|
||||
availability[action.Key] = action.Available
|
||||
}
|
||||
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
|
||||
for _, key := range []string{"generate-run", "push-run-update", "dependencies-check", "dependencies-install"} {
|
||||
if !availability[key] {
|
||||
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
|
||||
}
|
||||
}
|
||||
if availability["generate-client-manager"] {
|
||||
t.Fatalf("legacy client-manager action should not be advertised in %+v", actions.Actions)
|
||||
}
|
||||
|
||||
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
|
||||
if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
|
||||
@@ -333,25 +360,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
|
||||
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
|
||||
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
|
||||
}
|
||||
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
|
||||
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
|
||||
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
|
||||
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
|
||||
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
|
||||
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
|
||||
}
|
||||
deployRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/deploy", dto.ClientManagerDeployRequest{ProfileKey: "scum-client-manager", DistributionID: clientLinux.ID, IdempotencyKey: "api-client-manager-deploy"}, adminSession)
|
||||
assertErrorResponse(t, deployRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||
detail := getJSONWithAuth[dto.ClientManagerInstallationResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/scum-client-manager", adminSession)
|
||||
if detail.CurrentJobID != "" || detail.KeyGeneration <= 0 {
|
||||
t.Fatalf("unexpected client-manager lifecycle detail: %+v", detail)
|
||||
}
|
||||
unauthorizedLifecycle := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", "")
|
||||
assertErrorResponse(t, unauthorizedLifecycle, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/generate", map[string]string{"profileKey": "scum-client-manager"}, adminSession), http.StatusNotFound)
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", adminSession), http.StatusNotFound)
|
||||
|
||||
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
|
||||
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
|
||||
@@ -379,12 +389,9 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
if runReset.Generation != 2 || runReset.SecretRef == "" {
|
||||
t.Fatalf("unexpected run key reset: %+v", runReset)
|
||||
}
|
||||
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
|
||||
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
|
||||
t.Fatalf("unexpected client key reset: %+v", clientReset)
|
||||
}
|
||||
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession), http.StatusNotFound)
|
||||
|
||||
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} {
|
||||
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, runReset), mustJSON(t, dependencyInstall)} {
|
||||
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
|
||||
@@ -760,7 +767,7 @@ func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
|
||||
ready := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-management",
|
||||
ID: "server-ops",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM Ops",
|
||||
@@ -768,7 +775,7 @@ func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
}, adminSession)
|
||||
|
||||
newName := "SCUM Ops Renamed"
|
||||
updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-management", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession)
|
||||
updated := putJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/server-ops", dto.ServerInstanceUpdateRequest{Name: &newName}, adminSession)
|
||||
if updated.Name != newName || updated.PluginID != ready.PluginID || updated.RunEndpointID != ready.RunEndpointID {
|
||||
t.Fatalf("unexpected server update: %+v", updated)
|
||||
}
|
||||
@@ -783,16 +790,16 @@ func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
unsafeDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
assertErrorResponse(t, unsafeDelete, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-ops", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
assertStatus(t, deleted, http.StatusNoContent)
|
||||
activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
||||
for _, item := range activeList.Items {
|
||||
if item.ID == "server-management" {
|
||||
if item.ID == "server-ops" {
|
||||
t.Fatalf("deleted server should be hidden from normal list: %+v", activeList)
|
||||
}
|
||||
}
|
||||
deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession)
|
||||
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-ops" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||
t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList)
|
||||
}
|
||||
forcedDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local", Force: true, Confirmation: service.ServerDeletionForceConfirmation}), adminSession)
|
||||
@@ -1065,6 +1072,7 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
||||
adminSession := createAdminSession(t, router)
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
registration := validGamePluginManifestRegistrationRequest()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityConfigWrite)
|
||||
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
||||
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
||||
@@ -1110,19 +1118,20 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
||||
}, adminSession)
|
||||
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||
configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
Prompt: "Turn off pvp and keep this reviewable",
|
||||
Prompt: "Turn off pvp and apply it now",
|
||||
CurrentConfig: "server.name=AI API Server\n",
|
||||
}, adminSession)
|
||||
assertStatus(t, configRecorder, http.StatusOK)
|
||||
config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder)
|
||||
if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") {
|
||||
t.Fatalf("expected reviewable config suggestion, got %+v", config)
|
||||
if config.SuggestedConfig == "" || config.ConfigExecution == nil || config.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected immediately dispatched config suggestion, got %+v", config)
|
||||
}
|
||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession)
|
||||
if jobs.Count != 0 {
|
||||
t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs)
|
||||
if jobs.Count != 1 || !containsJobID(jobs.Items, config.ConfigExecution.Job.ID) {
|
||||
t.Fatalf("AI suggestion must dispatch one config write, got %+v", jobs)
|
||||
}
|
||||
|
||||
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
||||
@@ -1415,7 +1424,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
||||
t.Fatalf("expected permission denied safe envelope, got %+v", denied)
|
||||
}
|
||||
|
||||
unsafe := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
||||
unsafe := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
||||
RequestID: "bridge-unsafe-1",
|
||||
PluginID: "game.example",
|
||||
RouteKey: "logs",
|
||||
@@ -1423,9 +1432,11 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
|
||||
Action: string(domain.PluginBridgeActionFilesRequest),
|
||||
Payload: map[string]string{"key": "/Users/tasia/.ssh/id_rsa", "idempotencyKey": "idem-unsafe"},
|
||||
}, ownerSession)
|
||||
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
||||
if unsafe.Status != "error" || unsafe.Error == nil || unsafe.Error.Code != "execution_failed" {
|
||||
t.Fatalf("expected scoped file validation envelope, got %+v", unsafe)
|
||||
}
|
||||
|
||||
for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied)} {
|
||||
for _, body := range []string{mustJSON(t, serverRead), mustJSON(t, logs), mustJSON(t, lifecycle), mustJSON(t, lifecycleMismatch), mustJSON(t, fileDispatch), mustJSON(t, aiResponse), mustJSON(t, denied), mustJSON(t, unsafe)} {
|
||||
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("bridge response exposed forbidden fragment %q: %s", forbidden, body)
|
||||
@@ -1534,7 +1545,7 @@ func TestGamePluginManifestAPISafelyProjectsDLLReleaseDeclaration(t *testing.T)
|
||||
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
|
||||
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
|
||||
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
||||
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
|
||||
TargetExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
|
||||
}}}
|
||||
|
||||
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
|
||||
@@ -1588,22 +1599,16 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
||||
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
||||
}
|
||||
|
||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-direct", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigExecution == nil || invocation.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected direct AI config write dispatch, got %+v", invocation)
|
||||
}
|
||||
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
||||
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
||||
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
||||
}
|
||||
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
||||
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
||||
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
||||
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId="+serverID, adminSession)
|
||||
if jobs.Count < 2 || !containsJobID(jobs.Items, invocation.ConfigExecution.Job.ID) {
|
||||
t.Fatalf("expected AI config write job in durable job list, got %+v", jobs)
|
||||
}
|
||||
|
||||
evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval)
|
||||
evidence := fmt.Sprintf("%+v %+v %+v", lifecycle, lifecycles, invocation)
|
||||
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||
if strings.Contains(evidence, forbidden) {
|
||||
t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence)
|
||||
@@ -1841,30 +1846,22 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
}
|
||||
pluginRequest.DeclaredPermissions = []string{
|
||||
"server.read",
|
||||
"server.logs.read",
|
||||
"server.run.distribution",
|
||||
"server.client-manager.manage",
|
||||
"server.dependencies.manage",
|
||||
"server.artifacts.read",
|
||||
}
|
||||
pluginRequest.BridgeActions = []string{
|
||||
string(domain.PluginBridgeActionRunDistribution),
|
||||
string(domain.PluginBridgeActionClientManager),
|
||||
string(domain.PluginBridgeActionDependenciesRequest),
|
||||
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||
}
|
||||
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
|
||||
endpoint := validRunEndpointRequest()
|
||||
@@ -1877,11 +1874,6 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
|
||||
|
||||
@@ -1996,7 +1988,7 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
|
||||
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
@@ -2014,3 +2006,12 @@ func validRunEndpointRequest() dto.RunEndpointCreateRequest {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsJobID(items []dto.JobResponse, id string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+20
-24
@@ -14,7 +14,7 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
|
||||
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
||||
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
|
||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
|
||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `POST /api/v1/server-instances/{id}/files/browse`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
|
||||
@@ -27,8 +27,6 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
|
||||
|
||||
Plugin-owned data is an independent, server-scoped plugin store. It is not a projection of the game-server database and never aliases platform user/auth storage.
|
||||
|
||||
Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses.
|
||||
|
||||
## Implemented Query Filters
|
||||
|
||||
- `GET /api/v1/users?status=active`
|
||||
@@ -40,6 +38,7 @@ Client Manager lifecycle routes are grouped under the server instance and return
|
||||
- `GET /api/v1/metrics/server-instances`
|
||||
- `GET /api/v1/run/endpoints?status=online`
|
||||
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
|
||||
- `GET /api/v1/jobs?serverInstanceId=server-1&states=queued,running,failed&limit=100`
|
||||
- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
|
||||
- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout`
|
||||
|
||||
@@ -71,7 +70,7 @@ Server owner membership actions hide and reject platform administrators. Server
|
||||
|
||||
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
|
||||
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
|
||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text.
|
||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns typed recommendation and execution metadata; Platform validates and dispatches the bounded config.write job immediately rather than exposing a separate approval queue.
|
||||
|
||||
Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
||||
|
||||
@@ -86,10 +85,10 @@ File dispatch responses expose only logical target keys, scoped input/artifact r
|
||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional config recommendation, direct `configExecution.job` metadata, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` after the bounded config.write job is queued.
|
||||
|
||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions validate the current config revision/checksum, permissions, runtime binding, endpoint/plugin capability, and content safety before dispatching one bounded run-side write job.
|
||||
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
||||
|
||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||
@@ -116,8 +115,6 @@ Marketplace catalog state remains separate from production lifecycle installatio
|
||||
|
||||
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
||||
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, and idempotency before creating one durable Run job.
|
||||
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
||||
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
||||
|
||||
These responses expose logical IDs, states, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
||||
|
||||
@@ -135,10 +132,12 @@ Artifact bridge execution returns safe metadata and platform content routes only
|
||||
- `POST /api/v1/server-instances/workflows/create`: accepts `ServerLifecycleCreateRequest`. Creation starts from `pluginId` and `name`, and may include the create-wizard deployment definition such as deployment mode, plugin create inputs, server root, or custom start command. The browser does not submit a deployment target, Run endpoint, lifecycle profile, or Run identity binding. Platform applies plugin defaults, creates the definition without waiting for a Run, and attaches the active Run when its authenticated heartbeat arrives. Unknown legacy binding fields are rejected by the strict JSON decoder.
|
||||
- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/restart`: accept `ServerLifecycleCommandRequest`, validate the instance is `running` or `stopped` with a matching config version and run capability, and queue the plugin-declared stop action using `ServerLifecycleResponse`. When that stop job succeeds, Platform queues the plugin-declared start action for the same instance.
|
||||
- `POST /api/v1/server-instances/{id}/update`: accept `ServerLifecycleCommandRequest`, validate the instance is `running`, `stopped`, `ready`, or `failed` with a matching config version and `process.install` run capability, and queue the plugin-declared install/update action using `ServerLifecycleResponse`. The plugin install action owns the graceful close of a running server; Platform queues the plugin-declared start action after the update succeeds.
|
||||
|
||||
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
|
||||
|
||||
## Implemented Runtime Distribution And Client Manager Actions
|
||||
## Implemented Runtime Distribution Actions
|
||||
|
||||
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
|
||||
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
|
||||
@@ -148,17 +147,14 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`.
|
||||
- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and summary for the authorized server.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`.
|
||||
- `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`.
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries, and it drops accepted log batches whose source entry timestamps are older than the current SSE connection after a small clock-skew allowance; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events, or a scoped durable job log stream when `jobId` is supplied. It does not interpret log bodies; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
|
||||
SCUM product APIs expose platform-owned `scum_user`, trajectory, vehicle, and lock table records plus typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. The browser reads one bounded server surface (`GET /api/v1/server-instances/{id}/scum/surface`) instead of issuing one list call per table. Signed Run facts arrive through `POST /api/v1/run/scum/facts`; browser Run endpoint management under `/api/v1/run/endpoints` keeps the normal bearer/admin authorization while the remaining `/api/v1/run/` paths belong to the signed machine channels. These APIs never expose direct game database SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
|
||||
|
||||
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
|
||||
|
||||
@@ -185,7 +181,7 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
|
||||
- `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs.
|
||||
- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result.
|
||||
|
||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
||||
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job detail responses expose safe execution-result evidence; Job list responses stay summary-only for hot console polling. Job responses never expose raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
|
||||
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
|
||||
|
||||
## Implemented Run File Transfer Actions
|
||||
@@ -196,30 +192,30 @@ Run file input chunks are used only for browser-staged file uploads that produce
|
||||
|
||||
## Implemented Log Ingest Actions
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries verbatim, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
||||
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
|
||||
Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
|
||||
Server-scoped SSE log streaming remains available for the terminal drawer. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
|
||||
|
||||
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
||||
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Log bodies are retained and relayed verbatim; Platform does not parse, redact, filter, or derive plugin records from them. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
|
||||
|
||||
## Implemented Run Artifact Actions
|
||||
|
||||
- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state.
|
||||
- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
|
||||
- `POST /api/v1/run/artifacts/chunks`: accept `application/octet-stream` chunk bytes with transfer metadata in headers, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
|
||||
- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index.
|
||||
- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`.
|
||||
|
||||
Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
|
||||
Run artifact actions carry bounded upload metadata and raw chunk bodies only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and `application/octet-stream` chunk bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
|
||||
Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them.
|
||||
|
||||
## Implemented Browser Artifact Download Actions
|
||||
|
||||
- `GET /api/v1/artifacts/{id}`: returns authorized artifact metadata for the current bearer session.
|
||||
- `POST /api/v1/artifacts/{id}/download`: returns `ArtifactDownloadReferenceResponse` with filename, content type, size, checksum, expiry, supported chunk size, and a platform-owned `downloadUrl`.
|
||||
- `GET /api/v1/artifacts/{id}/content`: returns a bounded byte range using `offset`/`limit` query parameters or a `Range: bytes=start-end` header. Responses include `Content-Length`, `Accept-Ranges`, optional `Content-Range`, `X-Artifact-Checksum`, `X-Artifact-Content-Checksum`, and `X-Artifact-Storage` headers.
|
||||
- `GET /api/v1/artifacts/{id}/content`: streams the full artifact when no range is supplied, or returns a bounded byte range using `offset`/`limit` query parameters or a `Range: bytes=start-end` header. Responses include `Content-Length`, `Accept-Ranges`, optional `Content-Range`, `X-Artifact-Checksum`, `X-Artifact-Content-Checksum`, and `X-Artifact-Storage` headers.
|
||||
|
||||
Browser artifact downloads require an available artifact plus user access to the owning job/server context. Platform/plugin-owned artifacts are limited to platform administrators until a future storage policy adds narrower ownership. Current content reads use the private durable artifact body store; external object storage adapters are deferred behind the same service contract. Browser and plugin pages receive only platform routes and integrity metadata, never raw storage backend URLs, host paths, direct run sockets, run tokens, bearer tokens, or storage credentials.
|
||||
|
||||
@@ -255,7 +251,7 @@ These route groups remain documented future work beyond the currently implemente
|
||||
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
|
||||
- Live AI provider connectivity tests and remote model discovery.
|
||||
- Production Run distribution signing/KMS, fleet rollout rings, and real AI-provider integration.
|
||||
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
|
||||
- Server delete/archive routes beyond the currently implemented lifecycle, metadata update, and archive actions.
|
||||
|
||||
## Core Service Boundary
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
runEndpointHeader = "X-Run-Endpoint"
|
||||
runTimestampHeader = "X-Run-Timestamp"
|
||||
runNonceHeader = "X-Run-Nonce"
|
||||
runSignatureHeader = "X-Run-Signature"
|
||||
runEndpointHeader = "X-Run-Endpoint"
|
||||
runSessionTokenHeader = "X-Run-Session-Token"
|
||||
runTimestampHeader = "X-Run-Timestamp"
|
||||
runNonceHeader = "X-Run-Nonce"
|
||||
runSignatureHeader = "X-Run-Signature"
|
||||
)
|
||||
|
||||
type runRequestEnvelope struct {
|
||||
@@ -37,8 +38,8 @@ func (h *coreHandlers) requireRunSignature(next http.HandlerFunc) http.HandlerFu
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
var envelope runRequestEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
envelope, ok := signedRunRequestEnvelope(r, body)
|
||||
if !ok {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
@@ -65,3 +66,19 @@ func (h *coreHandlers) requireRunSignature(next http.HandlerFunc) http.HandlerFu
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func signedRunRequestEnvelope(r *http.Request, body []byte) (runRequestEnvelope, bool) {
|
||||
if isOctetStream(r.Header.Get("Content-Type")) {
|
||||
return runRequestEnvelope{RunEndpointID: strings.TrimSpace(r.Header.Get(runEndpointHeader)), SessionToken: strings.TrimSpace(r.Header.Get(runSessionTokenHeader))}, true
|
||||
}
|
||||
var envelope runRequestEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return runRequestEnvelope{}, false
|
||||
}
|
||||
return envelope, true
|
||||
}
|
||||
|
||||
func isOctetStream(contentType string) bool {
|
||||
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
return mediaType == "application/octet-stream"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) serverSCUMUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, ok := scumUserFilterFromRequest(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMUsersForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMUserListFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMUserTrajectories(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, ok := scumUserTrajectoryFilterFromRequest(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMUserTrajectoriesForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMUserTrajectoryListFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, ok := scumVehicleFilterFromRequest(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMVehicleListFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMVehicleTrajectories(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, ok := scumVehicleTrajectoryFilterFromRequest(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMVehicleTrajectoriesForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMVehicleTrajectoryListFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMVehicleLocks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, ok := scumVehicleLockFilterFromRequest(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMVehicleLocksForSession(bearerToken(r), filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMVehicleLockListFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMSurface(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
value, err := h.core.GetSCUMSurfaceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMSurfaceFromDomain(value))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) runSCUMFacts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.SCUMFactIngestRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.IngestSCUMFacts(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.SCUMFactIngestFromDomain(result))
|
||||
}
|
||||
|
||||
func scumUserFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMUserFilter, bool) {
|
||||
limit, ok := optionalLimitFromRequest(w, r)
|
||||
if !ok {
|
||||
return domain.SCUMUserFilter{}, false
|
||||
}
|
||||
online, ok := optionalBoolFromRequest(w, r, "online")
|
||||
if !ok {
|
||||
return domain.SCUMUserFilter{}, false
|
||||
}
|
||||
changedAfter, ok := optionalTimeFromRequest(w, r, "changedAfter")
|
||||
if !ok {
|
||||
return domain.SCUMUserFilter{}, false
|
||||
}
|
||||
return domain.SCUMUserFilter{ServerInstanceID: serverID, SteamID: r.URL.Query().Get("steamId"), Online: online, ChangedAfter: changedAfter, Limit: limit}, true
|
||||
}
|
||||
|
||||
func scumUserTrajectoryFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMUserTrajectoryFilter, bool) {
|
||||
limit, ok := optionalLimitFromRequest(w, r)
|
||||
if !ok {
|
||||
return domain.SCUMUserTrajectoryFilter{}, false
|
||||
}
|
||||
after, ok := optionalTimeFromRequest(w, r, "after")
|
||||
if !ok {
|
||||
return domain.SCUMUserTrajectoryFilter{}, false
|
||||
}
|
||||
return domain.SCUMUserTrajectoryFilter{ServerInstanceID: serverID, SCUMUserID: r.URL.Query().Get("scumUserId"), SteamID: r.URL.Query().Get("steamId"), After: after, Limit: limit}, true
|
||||
}
|
||||
|
||||
func scumVehicleFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleFilter, bool) {
|
||||
limit, ok := optionalLimitFromRequest(w, r)
|
||||
if !ok {
|
||||
return domain.SCUMVehicleFilter{}, false
|
||||
}
|
||||
exists, ok := optionalBoolFromRequest(w, r, "exists")
|
||||
if !ok {
|
||||
return domain.SCUMVehicleFilter{}, false
|
||||
}
|
||||
changedAfter, ok := optionalTimeFromRequest(w, r, "changedAfter")
|
||||
if !ok {
|
||||
return domain.SCUMVehicleFilter{}, false
|
||||
}
|
||||
return domain.SCUMVehicleFilter{ServerInstanceID: serverID, GameVehicleID: r.URL.Query().Get("gameVehicleId"), Exists: exists, ChangedAfter: changedAfter, Limit: limit}, true
|
||||
}
|
||||
|
||||
func scumVehicleTrajectoryFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleTrajectoryFilter, bool) {
|
||||
limit, ok := optionalLimitFromRequest(w, r)
|
||||
if !ok {
|
||||
return domain.SCUMVehicleTrajectoryFilter{}, false
|
||||
}
|
||||
after, ok := optionalTimeFromRequest(w, r, "after")
|
||||
if !ok {
|
||||
return domain.SCUMVehicleTrajectoryFilter{}, false
|
||||
}
|
||||
return domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: serverID, SCUMVehicleID: r.URL.Query().Get("scumVehicleId"), GameVehicleID: r.URL.Query().Get("gameVehicleId"), After: after, Limit: limit}, true
|
||||
}
|
||||
|
||||
func scumVehicleLockFilterFromRequest(w http.ResponseWriter, r *http.Request, serverID string) (domain.SCUMVehicleLockFilter, bool) {
|
||||
limit, ok := optionalLimitFromRequest(w, r)
|
||||
if !ok {
|
||||
return domain.SCUMVehicleLockFilter{}, false
|
||||
}
|
||||
after, ok := optionalTimeFromRequest(w, r, "after")
|
||||
if !ok {
|
||||
return domain.SCUMVehicleLockFilter{}, false
|
||||
}
|
||||
query := r.URL.Query()
|
||||
return domain.SCUMVehicleLockFilter{ServerInstanceID: serverID, SCUMVehicleID: query.Get("scumVehicleId"), GameVehicleID: query.Get("gameVehicleId"), SCUMUserID: query.Get("scumUserId"), SteamID: query.Get("steamId"), After: after, Limit: limit}, true
|
||||
}
|
||||
|
||||
func optionalLimitFromRequest(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid limit", nil)
|
||||
return 0, false
|
||||
}
|
||||
return limit, true
|
||||
}
|
||||
|
||||
func optionalBoolFromRequest(w http.ResponseWriter, r *http.Request, key string) (*bool, bool) {
|
||||
value := r.URL.Query().Get(key)
|
||||
if value == "" {
|
||||
return nil, true
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid "+key, nil)
|
||||
return nil, false
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
|
||||
func optionalTimeFromRequest(w http.ResponseWriter, r *http.Request, key string) (time.Time, bool) {
|
||||
value := r.URL.Query().Get(key)
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid "+key, nil)
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
@@ -160,6 +160,68 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverInstanceRestart godoc
|
||||
// @Summary Restart server instance
|
||||
// @Description Validates lifecycle state and config version, queues the plugin-declared stop action, and queues the plugin-declared start action after the graceful stop result lands.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
|
||||
// @Success 202 {object} dto.ServerLifecycleResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/restart [post]
|
||||
func (h *coreHandlers) serverInstanceRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RestartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverInstanceUpdate godoc
|
||||
// @Summary Update server game files
|
||||
// @Description Validates lifecycle state and config version, queues the plugin-declared install/update action, and queues the plugin-declared start action after the update result lands.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
|
||||
// @Success 202 {object} dto.ServerLifecycleResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/update [post]
|
||||
func (h *coreHandlers) serverInstanceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.UpdateServerGameForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
|
||||
}
|
||||
|
||||
// serverInstanceProcessStatus godoc
|
||||
// @Summary Query supervised server process status
|
||||
// @Description Queues a typed process.status job for the selected server/profile.
|
||||
|
||||
@@ -155,62 +155,6 @@ func (h *coreHandlers) serverDependencies(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, dto.DependencyCatalogFromDomain(catalog))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerBuildRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
distribution, err := h.core.GenerateClientManagerDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.ClientManagerDistributionFromDomain(distribution))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ClientManagerDownloadRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
reference, err := h.core.OpenLatestClientManagerDistributionDownloadForSession(bearerToken(r), r.PathValue("id"), request.ProfileKey)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverClientManagerKeyReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.ComponentKeyResetRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
reset := request.ToDomain(r.PathValue("id"))
|
||||
reset.ComponentKind = domain.DistributionComponentClientManager
|
||||
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), reset)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverDependenciesCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Platform Distribution Builder
|
||||
|
||||
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run 和 client-manager distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
|
||||
平台使用此专用镜像在一次性、只读 Docker 容器中构建 Run distribution。镜像只提供固定版本的 Go 工具链、Git 和 CA certificates;源码、每个 job 的输入与输出均由平台在运行时挂载。
|
||||
|
||||
构建本地固定标签:
|
||||
|
||||
|
||||
@@ -25,8 +25,11 @@ type AIConfigRecommendation struct {
|
||||
Key string
|
||||
SuggestedConfig string
|
||||
DiffSummary string
|
||||
DiffID string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type AIConfigExecution struct {
|
||||
Status string
|
||||
Job Job
|
||||
}
|
||||
|
||||
type AIInvocationSafeError struct {
|
||||
@@ -43,6 +46,7 @@ type AIInvocationResponse struct {
|
||||
Status string
|
||||
Recommendation string
|
||||
ConfigRecommendation *AIConfigRecommendation
|
||||
ConfigExecution *AIConfigExecution
|
||||
Usage AIInvocationUsage
|
||||
Error *AIInvocationSafeError
|
||||
}
|
||||
@@ -64,6 +68,11 @@ func CopyAIInvocationResponse(response AIInvocationResponse) AIInvocationRespons
|
||||
recommendation := *response.ConfigRecommendation
|
||||
response.ConfigRecommendation = &recommendation
|
||||
}
|
||||
if response.ConfigExecution != nil {
|
||||
execution := *response.ConfigExecution
|
||||
execution.Job = CopyJob(execution.Job)
|
||||
response.ConfigExecution = &execution
|
||||
}
|
||||
if response.Error != nil {
|
||||
errorCopy := *response.Error
|
||||
errorCopy.Details = CopyStringSlice(errorCopy.Details)
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type ClientManagerLifecycleStatus string
|
||||
|
||||
const (
|
||||
ClientManagerLifecycleRequested ClientManagerLifecycleStatus = "requested"
|
||||
ClientManagerLifecycleBuilding ClientManagerLifecycleStatus = "building"
|
||||
ClientManagerLifecycleAvailable ClientManagerLifecycleStatus = "available"
|
||||
ClientManagerLifecycleDeploying ClientManagerLifecycleStatus = "deploying"
|
||||
ClientManagerLifecycleInstalled ClientManagerLifecycleStatus = "installed"
|
||||
ClientManagerLifecycleRegistering ClientManagerLifecycleStatus = "registering"
|
||||
ClientManagerLifecycleOnline ClientManagerLifecycleStatus = "online"
|
||||
ClientManagerLifecycleDegraded ClientManagerLifecycleStatus = "degraded"
|
||||
ClientManagerLifecycleOffline ClientManagerLifecycleStatus = "offline"
|
||||
ClientManagerLifecycleUpdating ClientManagerLifecycleStatus = "updating"
|
||||
ClientManagerLifecycleRollingBack ClientManagerLifecycleStatus = "rolling_back"
|
||||
ClientManagerLifecycleStopping ClientManagerLifecycleStatus = "stopping"
|
||||
ClientManagerLifecycleUninstalled ClientManagerLifecycleStatus = "uninstalled"
|
||||
ClientManagerLifecycleFailed ClientManagerLifecycleStatus = "failed"
|
||||
)
|
||||
|
||||
type ClientManagerHealthStatus string
|
||||
|
||||
const (
|
||||
ClientManagerHealthUnknown ClientManagerHealthStatus = "unknown"
|
||||
ClientManagerHealthHealthy ClientManagerHealthStatus = "healthy"
|
||||
ClientManagerHealthDegraded ClientManagerHealthStatus = "degraded"
|
||||
ClientManagerHealthUnhealthy ClientManagerHealthStatus = "unhealthy"
|
||||
ClientManagerHealthOffline ClientManagerHealthStatus = "offline"
|
||||
)
|
||||
|
||||
type ClientManagerLifecycleOperation string
|
||||
|
||||
const (
|
||||
ClientManagerOperationDeploy ClientManagerLifecycleOperation = "deploy"
|
||||
ClientManagerOperationStart ClientManagerLifecycleOperation = "start"
|
||||
ClientManagerOperationStop ClientManagerLifecycleOperation = "stop"
|
||||
ClientManagerOperationRestart ClientManagerLifecycleOperation = "restart"
|
||||
ClientManagerOperationStatus ClientManagerLifecycleOperation = "status"
|
||||
ClientManagerOperationUpdate ClientManagerLifecycleOperation = "update"
|
||||
ClientManagerOperationRollback ClientManagerLifecycleOperation = "rollback"
|
||||
ClientManagerOperationUninstall ClientManagerLifecycleOperation = "uninstall"
|
||||
)
|
||||
|
||||
const (
|
||||
JobCapabilityClientManagerDeploy = "client-manager.deploy"
|
||||
JobCapabilityClientManagerControl = "client-manager.control"
|
||||
JobCapabilityClientManagerUpdate = "client-manager.update"
|
||||
JobCapabilityClientManagerRollback = "client-manager.rollback"
|
||||
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
|
||||
)
|
||||
|
||||
const ClientManagerCompanionConfigSchemaVersion = 1
|
||||
|
||||
type ClientManagerSessionStatus string
|
||||
|
||||
const (
|
||||
ClientManagerSessionActive ClientManagerSessionStatus = "active"
|
||||
ClientManagerSessionRevoked ClientManagerSessionStatus = "revoked"
|
||||
ClientManagerSessionExpired ClientManagerSessionStatus = "expired"
|
||||
)
|
||||
|
||||
type RuntimeClientManagerDeployment struct {
|
||||
Mode string
|
||||
ExecutableRef string
|
||||
Arguments []string
|
||||
AutoStart bool
|
||||
RequiredRunCapabilities []string
|
||||
}
|
||||
|
||||
type RuntimeClientManagerLifecycle struct {
|
||||
Actions []string
|
||||
StartupTimeoutSeconds int
|
||||
StopTimeoutSeconds int
|
||||
}
|
||||
|
||||
type RuntimeClientManagerHealth struct {
|
||||
Mode string
|
||||
IntervalSeconds int
|
||||
DegradedAfterSeconds int
|
||||
OfflineAfterSeconds int
|
||||
RequiredCapabilities []string
|
||||
}
|
||||
|
||||
type RuntimeClientManagerCompatibility struct {
|
||||
MinimumVersion string
|
||||
MaximumVersion string
|
||||
AllowDowngrade bool
|
||||
}
|
||||
|
||||
type RuntimeClientManagerUpdatePolicy struct {
|
||||
Strategy string
|
||||
RequireApproval bool
|
||||
HealthConfirmationSeconds int
|
||||
RetainPrevious bool
|
||||
}
|
||||
|
||||
type ClientManagerInstallation struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
RunEndpointID string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
Status ClientManagerLifecycleStatus
|
||||
Phase string
|
||||
DesiredVersion string
|
||||
ActiveVersion string
|
||||
PreviousVersion string
|
||||
DesiredRevision string
|
||||
ActiveRevision string
|
||||
PreviousRevision string
|
||||
DesiredArtifactID string
|
||||
ActiveArtifactID string
|
||||
PreviousArtifactID string
|
||||
Checksum string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
CurrentJobID string
|
||||
LastSuccessfulJobID string
|
||||
LastOperation ClientManagerLifecycleOperation
|
||||
Health ClientManagerHealthStatus
|
||||
HealthReason string
|
||||
LastSeenAt time.Time
|
||||
LastHeartbeatSequence uint64
|
||||
Retryable bool
|
||||
RequiresRedeploy bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
InstalledAt time.Time
|
||||
UninstalledAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerSession struct {
|
||||
ID string
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
RunEndpointID string
|
||||
ArtifactID string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
TokenHash string
|
||||
Capabilities []string
|
||||
Status ClientManagerSessionStatus
|
||||
LastHeartbeatSequence uint64
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerRegistrationNonce struct {
|
||||
ID string
|
||||
InstallationID string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleActionAvailability struct {
|
||||
Operation ClientManagerLifecycleOperation
|
||||
Available bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleView struct {
|
||||
Installation ClientManagerInstallation
|
||||
Distribution ClientManagerDistribution
|
||||
Job Job
|
||||
Actions []ClientManagerLifecycleActionAvailability
|
||||
}
|
||||
|
||||
type ClientManagerDeployRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
DistributionID string
|
||||
ExpectedDeploymentGeneration int
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerControlRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
Operation ClientManagerLifecycleOperation
|
||||
ExpectedDeploymentGeneration int
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerUpdateRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
DistributionID string
|
||||
ExpectedDeploymentGeneration int
|
||||
Approved bool
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerUninstallRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
ExpectedDeploymentGeneration int
|
||||
Confirmed bool
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerRetryRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
ExpectedDeploymentGeneration int
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerRevokeSessionRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
JobID string
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleInput struct {
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
Operation ClientManagerLifecycleOperation
|
||||
ArtifactID string
|
||||
Checksum string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
Version string
|
||||
SourceRevision string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
ExecutableRef string
|
||||
Arguments []string
|
||||
AutoStart bool
|
||||
StartupTimeoutSeconds int
|
||||
StopTimeoutSeconds int
|
||||
HealthConfirmationSeconds int
|
||||
IdempotencyKey string
|
||||
CompanionConfig *ClientManagerCompanionConfigInput
|
||||
}
|
||||
|
||||
type ClientManagerCompanionConfigInput struct {
|
||||
SchemaVersion int
|
||||
ConfigTemplateKey string
|
||||
ConfigTemplateRef string
|
||||
ConfigOutputRef string
|
||||
ConfigSchemaRef string
|
||||
ConfigFormat string
|
||||
PlatformBaseURLSource string
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
ArtifactID string
|
||||
Version string
|
||||
SourceRevision string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
Capabilities []string
|
||||
RegistrationProof string
|
||||
ProofMaterialSource string
|
||||
ProofMaterialEnv string
|
||||
SessionMode string
|
||||
TLSPolicy string
|
||||
HeartbeatIntervalSeconds int
|
||||
CommandPollIntervalSeconds int
|
||||
RequestTimeoutSeconds int
|
||||
}
|
||||
|
||||
type ClientManagerRegisterRequest struct {
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
ArtifactID string
|
||||
Version string
|
||||
SourceRevision string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
KeyGeneration int
|
||||
DeploymentGeneration int
|
||||
Capabilities []string
|
||||
Timestamp time.Time
|
||||
Nonce string
|
||||
Signature string
|
||||
}
|
||||
|
||||
type ClientManagerRegisterResult struct {
|
||||
Accepted bool
|
||||
InstallationID string
|
||||
SessionToken string
|
||||
ExpiresAt time.Time
|
||||
HeartbeatEvery int
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type ClientManagerHeartbeat struct {
|
||||
InstallationID string
|
||||
SessionToken string
|
||||
Sequence uint64
|
||||
Health ClientManagerHealthStatus
|
||||
HealthReason string
|
||||
Capabilities []string
|
||||
SentAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerHeartbeatResult struct {
|
||||
Accepted bool
|
||||
InstallationID string
|
||||
Status ClientManagerLifecycleStatus
|
||||
Health ClientManagerHealthStatus
|
||||
NextHeartbeat int
|
||||
SessionExpiresAt time.Time
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type ClientManagerInstallationFilter struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
RunEndpointID string
|
||||
Status ClientManagerLifecycleStatus
|
||||
}
|
||||
|
||||
type ClientManagerSessionFilter struct {
|
||||
InstallationID string
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
Status ClientManagerSessionStatus
|
||||
}
|
||||
|
||||
type ClientManagerNonceFilter struct {
|
||||
InstallationID string
|
||||
ExpiresBefore time.Time
|
||||
}
|
||||
|
||||
func CopyClientManagerInstallation(value ClientManagerInstallation) ClientManagerInstallation {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerSession(value ClientManagerSession) ClientManagerSession {
|
||||
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerRegistrationNonce(value ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientManagerLifecycleView {
|
||||
value.Installation = CopyClientManagerInstallation(value.Installation)
|
||||
value.Distribution = CopyClientManagerDistribution(value.Distribution)
|
||||
value.Job = CopyJob(value.Job)
|
||||
value.Actions = append([]ClientManagerLifecycleActionAvailability(nil), value.Actions...)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
|
||||
value.Arguments = CopyStringSlice(value.Arguments)
|
||||
if value.CompanionConfig != nil {
|
||||
companion := CopyClientManagerCompanionConfigInput(*value.CompanionConfig)
|
||||
value.CompanionConfig = &companion
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerCompanionConfigInput(value ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInput {
|
||||
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerRegisterRequest(value ClientManagerRegisterRequest) ClientManagerRegisterRequest {
|
||||
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyClientManagerHeartbeat(value ClientManagerHeartbeat) ClientManagerHeartbeat {
|
||||
value.Capabilities = CopyStringSlice(value.Capabilities)
|
||||
return value
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyClientManagerLifecycleInputDeepCopiesCompanionCapabilities(t *testing.T) {
|
||||
original := ClientManagerLifecycleInput{
|
||||
Arguments: []string{"--foreground"},
|
||||
CompanionConfig: &ClientManagerCompanionConfigInput{
|
||||
Capabilities: []string{"component.register", "game-client.bridge"},
|
||||
},
|
||||
}
|
||||
cloned := CopyClientManagerLifecycleInput(original)
|
||||
cloned.Arguments[0] = "--changed"
|
||||
cloned.CompanionConfig.Capabilities[0] = "changed"
|
||||
|
||||
if original.Arguments[0] != "--foreground" {
|
||||
t.Fatalf("arguments were not deeply copied: %+v", original.Arguments)
|
||||
}
|
||||
if original.CompanionConfig.Capabilities[0] != "component.register" {
|
||||
t.Fatalf("companion capabilities were not deeply copied: %+v", original.CompanionConfig.Capabilities)
|
||||
}
|
||||
}
|
||||
@@ -59,25 +59,6 @@ type GameClientBridgeQueryProjectionDeclaration struct {
|
||||
MergeExisting bool
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclaration struct {
|
||||
Pattern string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclaration struct {
|
||||
Collection string
|
||||
UpsertKeys []string
|
||||
CaptureMappings map[string]string
|
||||
HashMappings map[string]string
|
||||
FixedValues map[string]string
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclaration struct {
|
||||
TimestampField string
|
||||
ActiveWindowSeconds int
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeLifecycleProjectionDeclaration struct {
|
||||
Key string
|
||||
Capabilities []string
|
||||
@@ -102,20 +83,9 @@ type GameClientBridgeBulkActivityTargetDeclaration struct {
|
||||
ObservedAtField string
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclaration struct {
|
||||
Key string
|
||||
StreamKeys []string
|
||||
Steps []GameClientBridgeLogProjectionStepDeclaration
|
||||
CorrelationFields []string
|
||||
MaxInterveningLines int
|
||||
Target GameClientBridgeLogProjectionTargetDeclaration
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclaration struct {
|
||||
Key string
|
||||
DatabaseUserVersion int
|
||||
LogParserRefs []string
|
||||
ConfigMapRefs []string
|
||||
DataRefs []string
|
||||
}
|
||||
@@ -136,33 +106,15 @@ type GameClientBridgeFeatureDeclaration struct {
|
||||
RequiredEventProducers []string
|
||||
}
|
||||
|
||||
type GameClientBridgeCompanionDeclaration struct {
|
||||
ProfileKey string
|
||||
ConfigTemplateKey string
|
||||
ConfigSchemaRef string
|
||||
ConfigFormat string
|
||||
PlatformBaseURLSource string
|
||||
RegistrationProof string
|
||||
ProofMaterialSource string
|
||||
ProofMaterialEnv string
|
||||
SessionMode string
|
||||
TLSPolicy string
|
||||
HeartbeatIntervalSeconds int
|
||||
CommandPollIntervalSeconds int
|
||||
RequestTimeoutSeconds int
|
||||
}
|
||||
|
||||
type GameClientBridgeManifest struct {
|
||||
Commands []GameClientBridgeCommandDeclaration
|
||||
Snapshots []GameClientBridgeSnapshotDeclaration
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||
LogProjections []GameClientBridgeLogProjectionDeclaration
|
||||
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration
|
||||
DataPacks []GameClientBridgeDataPackDeclaration
|
||||
Retention GameClientBridgeRetention
|
||||
Pages []GameClientBridgePageContract
|
||||
Features []GameClientBridgeFeatureDeclaration
|
||||
Companion GameClientBridgeCompanionDeclaration
|
||||
}
|
||||
|
||||
type GameClientBridgeResultStatus string
|
||||
@@ -330,6 +282,13 @@ type GameClientBridgeSnapshotIngestRequest struct {
|
||||
Retention GameClientBridgeRetention
|
||||
}
|
||||
|
||||
// GameClientBridgeLogStreamRequest authorizes a component to receive the
|
||||
// opaque log channel for its bound server. The platform never interprets the
|
||||
// log body; parsing and business projections remain plugin-owned.
|
||||
type GameClientBridgeLogStreamRequest struct {
|
||||
SessionToken string
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotQuery struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
@@ -436,17 +395,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
||||
for index := range value.QueryTemplates {
|
||||
value.QueryTemplates[index] = CopyGameClientBridgeQueryTemplateDeclaration(value.QueryTemplates[index])
|
||||
}
|
||||
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
|
||||
for index := range value.LogProjections {
|
||||
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
|
||||
}
|
||||
value.LifecycleProjections = append([]GameClientBridgeLifecycleProjectionDeclaration(nil), value.LifecycleProjections...)
|
||||
for index := range value.LifecycleProjections {
|
||||
value.LifecycleProjections[index] = CopyGameClientBridgeLifecycleProjectionDeclaration(value.LifecycleProjections[index])
|
||||
}
|
||||
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
|
||||
for index := range value.DataPacks {
|
||||
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
|
||||
value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs)
|
||||
value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs)
|
||||
}
|
||||
@@ -503,30 +457,6 @@ func CopyGameClientBridgeBulkActivityTargetDeclaration(value GameClientBridgeBul
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
|
||||
value.StreamKeys = CopyStringSlice(value.StreamKeys)
|
||||
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
|
||||
value.CorrelationFields = CopyStringSlice(value.CorrelationFields)
|
||||
value.Target = CopyGameClientBridgeLogProjectionTargetDeclaration(value.Target)
|
||||
if value.Presence != nil {
|
||||
presence := *value.Presence
|
||||
if presence.ActivityTarget != nil {
|
||||
activityTarget := CopyGameClientBridgeLogProjectionTargetDeclaration(*presence.ActivityTarget)
|
||||
presence.ActivityTarget = &activityTarget
|
||||
}
|
||||
value.Presence = &presence
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
|
||||
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
|
||||
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
|
||||
value.HashMappings = CopyStringMap(value.HashMappings)
|
||||
value.FixedValues = CopyStringMap(value.FixedValues)
|
||||
return value
|
||||
}
|
||||
|
||||
func copyGameClientBridgePayloadValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
|
||||
@@ -4,29 +4,19 @@ import "testing"
|
||||
|
||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||
manifest := GameClientBridgeManifest{
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}},
|
||||
LogProjections: []GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
||||
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
||||
}},
|
||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}},
|
||||
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
||||
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
||||
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
|
||||
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||
manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated"
|
||||
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
|
||||
manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated"
|
||||
manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
||||
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
|
||||
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
||||
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ type RunAutonomousLifecyclePlan struct {
|
||||
InstallPlans []RunAutonomousInstallPlan `json:"installPlans,omitempty"`
|
||||
LogSources []RunAutonomousLogSource `json:"logSources,omitempty"`
|
||||
DLLExtensions []RunAutonomousDLLExtension `json:"dllExtensions,omitempty"`
|
||||
DataTargets []RunAutonomousDataTarget `json:"dataTargets,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
Deployment *RunAutonomousDeployment `json:"deployment,omitempty"`
|
||||
}
|
||||
@@ -166,6 +167,7 @@ type RunAutonomousDependencyProbe struct {
|
||||
TargetKey string `json:"targetKey"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||
SteamAppID string `json:"steamAppId,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
@@ -196,17 +198,31 @@ type RunAutonomousLogSource struct {
|
||||
}
|
||||
|
||||
type RunAutonomousDLLExtension struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
RCONPort int `json:"rconPort,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
RCONPort int `json:"rconPort,omitempty"`
|
||||
}
|
||||
|
||||
// RunAutonomousDataTarget is a package-local snapshot declaration. The source
|
||||
// root is resolved only inside Run from a private generated package binding.
|
||||
type RunAutonomousDataTarget struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RunAutonomousDeployment struct {
|
||||
@@ -243,6 +259,7 @@ type DependencyExecutionInput struct {
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
PlanDigest string
|
||||
ServerRoot string
|
||||
Probe RuntimeDependencyProbe
|
||||
Plan RuntimeInstallPlan
|
||||
Bindings map[string]string
|
||||
@@ -471,6 +488,10 @@ func CopyRunAutonomousLifecyclePlanPtr(plan *RunAutonomousLifecyclePlan) *RunAut
|
||||
}
|
||||
copy.LogSources = append([]RunAutonomousLogSource(nil), plan.LogSources...)
|
||||
copy.DLLExtensions = append([]RunAutonomousDLLExtension(nil), plan.DLLExtensions...)
|
||||
copy.DataTargets = append([]RunAutonomousDataTarget(nil), plan.DataTargets...)
|
||||
for i := range copy.DataTargets {
|
||||
copy.DataTargets[i].Platforms = CopyStringSlice(plan.DataTargets[i].Platforms)
|
||||
}
|
||||
copy.RuntimeBindings = CopyStringMap(plan.RuntimeBindings)
|
||||
if plan.Deployment != nil {
|
||||
deployment := *plan.Deployment
|
||||
|
||||
@@ -8,7 +8,6 @@ type LogEntry struct {
|
||||
Level string
|
||||
Line string
|
||||
Fields map[string]string
|
||||
Redacted bool
|
||||
}
|
||||
|
||||
type LogBatchIngest struct {
|
||||
|
||||
@@ -66,56 +66,6 @@ type PluginLifecycleResult struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type AIConfigDiffState string
|
||||
|
||||
const (
|
||||
AIConfigDiffStatePending AIConfigDiffState = "pending"
|
||||
AIConfigDiffStateApproved AIConfigDiffState = "approved"
|
||||
AIConfigDiffStateCancelled AIConfigDiffState = "cancelled"
|
||||
AIConfigDiffStateExpired AIConfigDiffState = "expired"
|
||||
)
|
||||
|
||||
type AIConfigDiffPreview struct {
|
||||
ID string
|
||||
RequestID string
|
||||
CreatedBy string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProviderID string
|
||||
Model string
|
||||
Key string
|
||||
ConfigVersion int
|
||||
CurrentConfigChecksum string
|
||||
ProposedConfig string
|
||||
DiffSummary string
|
||||
State AIConfigDiffState
|
||||
ExpiresAt time.Time
|
||||
ApprovedBy string
|
||||
ApprovedAt time.Time
|
||||
ApprovalIdempotencyKey string
|
||||
CancelledBy string
|
||||
CancelledAt time.Time
|
||||
JobID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AIConfigDiffFilter struct {
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
State AIConfigDiffState
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
DiffID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResult struct {
|
||||
Preview AIConfigDiffPreview
|
||||
Dispatch ServerConfigWriteDispatch
|
||||
}
|
||||
|
||||
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
||||
return installation
|
||||
}
|
||||
@@ -134,22 +84,3 @@ func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResu
|
||||
result.Job = CopyJob(result.Job)
|
||||
return result
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreview(preview AIConfigDiffPreview) AIConfigDiffPreview {
|
||||
return preview
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffPreviews(previews []AIConfigDiffPreview) []AIConfigDiffPreview {
|
||||
if previews == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]AIConfigDiffPreview, len(previews))
|
||||
copy(out, previews)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyAIConfigDiffApprovalResult(result AIConfigDiffApprovalResult) AIConfigDiffApprovalResult {
|
||||
result.Preview = CopyAIConfigDiffPreview(result.Preview)
|
||||
result.Dispatch = CopyServerConfigWriteDispatch(result.Dispatch)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
// GamePluginIdentityKey collapses generated manifest IDs for the same first-party
|
||||
// plugin so platform storage keeps one current registry record per plugin.
|
||||
func GamePluginIdentityKey(plugin GamePlugin) string {
|
||||
baseID := CanonicalGamePluginID(plugin.ID)
|
||||
return "id:" + baseID
|
||||
}
|
||||
|
||||
func CanonicalGamePluginID(id string) string {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
if marker := strings.Index(id, ".codex."); marker > 0 {
|
||||
return id[:marker]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func GamePluginIsNewer(candidate GamePlugin, current GamePlugin) bool {
|
||||
if comparison := CompareGamePluginVersions(candidate.Version, current.Version); comparison != 0 {
|
||||
return comparison > 0
|
||||
}
|
||||
if score := gamePluginPreferenceScore(candidate) - gamePluginPreferenceScore(current); score != 0 {
|
||||
return score > 0
|
||||
}
|
||||
return strings.Compare(strings.ToLower(strings.TrimSpace(candidate.ID)), strings.ToLower(strings.TrimSpace(current.ID))) < 0
|
||||
}
|
||||
|
||||
func CompareGamePluginVersions(left string, right string) int {
|
||||
leftParts := gamePluginVersionParts(left)
|
||||
rightParts := gamePluginVersionParts(right)
|
||||
limit := len(leftParts)
|
||||
if len(rightParts) > limit {
|
||||
limit = len(rightParts)
|
||||
}
|
||||
for index := 0; index < limit; index++ {
|
||||
var leftValue, rightValue int
|
||||
if index < len(leftParts) {
|
||||
leftValue = leftParts[index]
|
||||
}
|
||||
if index < len(rightParts) {
|
||||
rightValue = rightParts[index]
|
||||
}
|
||||
if leftValue != rightValue {
|
||||
if leftValue > rightValue {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
leftText := strings.ToLower(strings.TrimSpace(left))
|
||||
rightText := strings.ToLower(strings.TrimSpace(right))
|
||||
if leftText == rightText {
|
||||
return 0
|
||||
}
|
||||
if leftText == "" {
|
||||
return -1
|
||||
}
|
||||
if rightText == "" {
|
||||
return 1
|
||||
}
|
||||
if strings.Contains(leftText, "-") != strings.Contains(rightText, "-") {
|
||||
if strings.Contains(leftText, "-") {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if leftText > rightText {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func gamePluginVersionParts(version string) []int {
|
||||
fields := strings.FieldsFunc(version, func(r rune) bool { return r < '0' || r > '9' })
|
||||
parts := make([]int, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
value := 0
|
||||
for _, r := range field {
|
||||
value = value*10 + int(r-'0')
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func gamePluginPreferenceScore(plugin GamePlugin) int {
|
||||
score := 0
|
||||
id := strings.ToLower(strings.TrimSpace(plugin.ID))
|
||||
if id == CanonicalGamePluginID(id) {
|
||||
score += 4
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(plugin.ManifestRef)), "artifact://") {
|
||||
score += 2
|
||||
}
|
||||
if plugin.Status == GamePluginStatusInstalled {
|
||||
score++
|
||||
}
|
||||
return score
|
||||
}
|
||||
+54
-196
@@ -108,8 +108,7 @@ const (
|
||||
type DistributionComponentKind string
|
||||
|
||||
const (
|
||||
DistributionComponentRun DistributionComponentKind = "run"
|
||||
DistributionComponentClientManager DistributionComponentKind = "client-manager"
|
||||
DistributionComponentRun DistributionComponentKind = "run"
|
||||
)
|
||||
|
||||
type ComponentKeyStatus string
|
||||
@@ -330,8 +329,8 @@ type PluginFileWorkspace struct {
|
||||
|
||||
type PluginLogicalDirectory struct{ Key, Label, Scope string }
|
||||
type PluginLogicalFile struct {
|
||||
Key, DirectoryKey, Label, Kind, StreamKey string
|
||||
Editable bool
|
||||
Key, DirectoryKey, Label, Kind, StreamKey, TargetKey string
|
||||
Editable bool
|
||||
}
|
||||
type PluginConfigField struct {
|
||||
Key, FileKey, ConfigKey, Label, Description, Control, DefaultValue, RestartImpact string
|
||||
@@ -364,9 +363,8 @@ type GamePluginManifestServer struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAI struct {
|
||||
Purposes []string
|
||||
Mediation string
|
||||
ConfigWritePolicy string
|
||||
Purposes []string
|
||||
Mediation string
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycle struct {
|
||||
@@ -402,7 +400,6 @@ type RuntimeLifecycleProfile struct {
|
||||
Capabilities []string
|
||||
ActionRefs PluginLifecycleActions
|
||||
TransportKeys []string
|
||||
ClientManagerRef string
|
||||
DLLExtensionRefs []string
|
||||
Platforms []string
|
||||
}
|
||||
@@ -413,6 +410,7 @@ type RuntimeDependencyProbe struct {
|
||||
TargetKey string
|
||||
Required bool
|
||||
MinimumVersion string
|
||||
SteamAppID string
|
||||
Platforms []string
|
||||
}
|
||||
|
||||
@@ -442,26 +440,6 @@ type RuntimeLogSource struct {
|
||||
RetentionDays int
|
||||
}
|
||||
|
||||
type RuntimeLogEventSeverity string
|
||||
|
||||
const (
|
||||
RuntimeLogEventSeverityInfo RuntimeLogEventSeverity = "info"
|
||||
RuntimeLogEventSeverityNotice RuntimeLogEventSeverity = "notice"
|
||||
RuntimeLogEventSeverityWarning RuntimeLogEventSeverity = "warning"
|
||||
RuntimeLogEventSeverityCritical RuntimeLogEventSeverity = "critical"
|
||||
)
|
||||
|
||||
type RuntimeLogEvent struct {
|
||||
Key string
|
||||
Title string
|
||||
SourceKey string
|
||||
EventType string
|
||||
Permission string
|
||||
SchemaRef string
|
||||
RetentionDays int
|
||||
Severity RuntimeLogEventSeverity
|
||||
}
|
||||
|
||||
type RuntimeTransportProfile struct {
|
||||
Key string
|
||||
Kind string
|
||||
@@ -469,64 +447,56 @@ type RuntimeTransportProfile struct {
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
type RuntimeClientManagerProfile struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
Version string
|
||||
RepositoryURL string
|
||||
RevisionPolicy string
|
||||
Branch string
|
||||
Tag string
|
||||
Revision string
|
||||
SupportedTargets []RuntimeTarget
|
||||
BuildSystem string
|
||||
WorkspaceRef string
|
||||
EntryRef string
|
||||
ConfigTemplates []RuntimeConfigTemplate
|
||||
OutputArtifacts []string
|
||||
Deployment RuntimeClientManagerDeployment
|
||||
Lifecycle RuntimeClientManagerLifecycle
|
||||
Health RuntimeClientManagerHealth
|
||||
Compatibility RuntimeClientManagerCompatibility
|
||||
UpdatePolicy RuntimeClientManagerUpdatePolicy
|
||||
// RuntimeDataTarget declares a bounded plugin-owned local data snapshot that
|
||||
// Run may materialize before a typed query. It never carries a host path.
|
||||
type RuntimeDataTarget struct {
|
||||
Key string
|
||||
Kind string
|
||||
TransportKey string
|
||||
SourceRootKey string
|
||||
SourcePath string
|
||||
WorkspaceKey string
|
||||
RefreshPolicy string
|
||||
MaxBytes int64
|
||||
Platforms []string
|
||||
}
|
||||
|
||||
type RuntimeDLLExtensionProfile struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Activation string
|
||||
Version string
|
||||
ReleaseState string
|
||||
ReleaseURL string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
TargetKey string
|
||||
ModKey string
|
||||
DLLRef string
|
||||
SCUMExecutableChecksum string
|
||||
UE4SSABI string
|
||||
SupportedTargets []RuntimeTarget
|
||||
UpdateOnStart bool
|
||||
RCONPort int
|
||||
Key string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Activation string
|
||||
Version string
|
||||
ReleaseState string
|
||||
ReleaseURL string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
TargetKey string
|
||||
ModKey string
|
||||
DLLRef string
|
||||
TargetExecutableChecksum string
|
||||
UE4SSABI string
|
||||
SupportedTargets []RuntimeTarget
|
||||
UpdateOnStart bool
|
||||
RCONPort int
|
||||
}
|
||||
|
||||
type RuntimeDLLExtensionPlan struct {
|
||||
Key string
|
||||
Version string
|
||||
ReleaseURL string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
TargetKey string
|
||||
ModKey string
|
||||
DLLRef string
|
||||
SCUMExecutableChecksum string
|
||||
UE4SSABI string
|
||||
RCONPort int
|
||||
Key string
|
||||
Version string
|
||||
ReleaseURL string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
TargetKey string
|
||||
ModKey string
|
||||
DLLRef string
|
||||
TargetExecutableChecksum string
|
||||
UE4SSABI string
|
||||
RCONPort int
|
||||
}
|
||||
|
||||
// RuntimeSourceRCONPlan is a frozen, secret-free loopback connection plan for
|
||||
// a ready SCUM UE4SS extension. The generated local config remains Run-owned.
|
||||
// a ready UE4SS extension. The generated local config remains Run-owned.
|
||||
type RuntimeSourceRCONPlan struct {
|
||||
Protocol string
|
||||
ExtensionKey string
|
||||
@@ -573,33 +543,14 @@ type RuntimeServerPrerequisite struct {
|
||||
Kind string
|
||||
}
|
||||
|
||||
// RuntimeServerDeploymentProfile is a legacy game-specific deployment template
|
||||
// declaration kept for backward-compatible manifest decoding.
|
||||
type RuntimeServerDeploymentProfile struct {
|
||||
Key string
|
||||
Version string
|
||||
SupportedTargets []RuntimeTarget
|
||||
SteamAppID string
|
||||
ExecutableKey string
|
||||
InstallRootKey string
|
||||
ConfigKey string
|
||||
ConfigFormat string
|
||||
Prerequisites []RuntimeServerPrerequisite
|
||||
ConfigMappings []RuntimeServerConfigMapping
|
||||
DiscoveryMarkers []RuntimeServerDiscoveryMarker
|
||||
VerificationChecks []RuntimeServerVerificationCheck
|
||||
}
|
||||
|
||||
type GamePluginRuntimeProfiles struct {
|
||||
Discovery []RuntimeDiscoveryProbe
|
||||
LifecycleProfiles []RuntimeLifecycleProfile
|
||||
DependencyProbes []RuntimeDependencyProbe
|
||||
InstallPlans []RuntimeInstallPlan
|
||||
ServerDeployments []RuntimeServerDeploymentProfile
|
||||
LogSources []RuntimeLogSource
|
||||
LogEvents []RuntimeLogEvent
|
||||
TransportProfiles []RuntimeTransportProfile
|
||||
ClientManagers []RuntimeClientManagerProfile
|
||||
DataTargets []RuntimeDataTarget
|
||||
DLLExtensions []RuntimeDLLExtensionProfile
|
||||
}
|
||||
|
||||
@@ -707,7 +658,6 @@ const (
|
||||
PluginBridgeActionRunDistribution PluginBridgeAction = "run.distribution.request"
|
||||
PluginBridgeActionDependenciesRequest PluginBridgeAction = "dependencies.request"
|
||||
PluginBridgeActionLogsBackfillRequest PluginBridgeAction = "logs.backfill.request"
|
||||
PluginBridgeActionClientManager PluginBridgeAction = "client-manager.request"
|
||||
PluginBridgeActionPluginLifecycle PluginBridgeAction = "plugin-lifecycle.request"
|
||||
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
|
||||
)
|
||||
@@ -1258,6 +1208,7 @@ type JobRetryPolicy struct {
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string
|
||||
Content string
|
||||
FileTargetKey string
|
||||
ExpectedVersion int
|
||||
ExpectedChecksum string
|
||||
MaxReadBytes int
|
||||
@@ -1439,26 +1390,6 @@ type RunDistribution struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerDistribution struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Version string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
BuildJobID string
|
||||
ArtifactID string
|
||||
Checksum string
|
||||
KeyGeneration int
|
||||
SecretRef string
|
||||
Status DistributionStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type DependencyStatus struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
@@ -1478,25 +1409,6 @@ type DependencyStatus struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ClientManagerBuildJob struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ProfileKey string
|
||||
Version string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
ArtifactID string
|
||||
Checksum string
|
||||
KeyGeneration int
|
||||
LogsRef string
|
||||
Status DistributionJobStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type RunUpdateJob struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
@@ -1524,16 +1436,6 @@ type RunDistributionGenerateRequest struct {
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ClientManagerBuildRequest struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
RepositoryURL string
|
||||
SourceRevision string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ComponentKeyResetRequest struct {
|
||||
ServerInstanceID string
|
||||
ComponentKind DistributionComponentKind
|
||||
@@ -1695,6 +1597,8 @@ type JobFilter struct {
|
||||
ServerInstanceID string
|
||||
RunEndpointID string
|
||||
State JobState
|
||||
States []JobState
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ArtifactFilter struct {
|
||||
@@ -1723,26 +1627,12 @@ type RunDistributionFilter struct {
|
||||
Status DistributionStatus
|
||||
}
|
||||
|
||||
type ClientManagerDistributionFilter struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
Status DistributionStatus
|
||||
}
|
||||
|
||||
type DependencyStatusFilter struct {
|
||||
ServerInstanceID string
|
||||
ProbeKey string
|
||||
State DependencyState
|
||||
}
|
||||
|
||||
type ClientManagerBuildJobFilter struct {
|
||||
ServerInstanceID string
|
||||
ProfileKey string
|
||||
Status DistributionJobStatus
|
||||
}
|
||||
|
||||
type RunUpdateJobFilter struct {
|
||||
ServerInstanceID string
|
||||
Status DistributionJobStatus
|
||||
@@ -1921,25 +1811,14 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
|
||||
profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms)
|
||||
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
|
||||
}
|
||||
profiles.ServerDeployments = append([]RuntimeServerDeploymentProfile(nil), profiles.ServerDeployments...)
|
||||
for i := range profiles.ServerDeployments {
|
||||
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
|
||||
}
|
||||
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
||||
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
|
||||
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
||||
for i := range profiles.TransportProfiles {
|
||||
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
||||
}
|
||||
profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||
for i := range profiles.ClientManagers {
|
||||
profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...)
|
||||
profiles.ClientManagers[i].ConfigTemplates = append([]RuntimeConfigTemplate(nil), profiles.ClientManagers[i].ConfigTemplates...)
|
||||
profiles.ClientManagers[i].OutputArtifacts = CopyStringSlice(profiles.ClientManagers[i].OutputArtifacts)
|
||||
profiles.ClientManagers[i].Deployment.Arguments = CopyStringSlice(profiles.ClientManagers[i].Deployment.Arguments)
|
||||
profiles.ClientManagers[i].Deployment.RequiredRunCapabilities = CopyStringSlice(profiles.ClientManagers[i].Deployment.RequiredRunCapabilities)
|
||||
profiles.ClientManagers[i].Lifecycle.Actions = CopyStringSlice(profiles.ClientManagers[i].Lifecycle.Actions)
|
||||
profiles.ClientManagers[i].Health.RequiredCapabilities = CopyStringSlice(profiles.ClientManagers[i].Health.RequiredCapabilities)
|
||||
profiles.DataTargets = append([]RuntimeDataTarget(nil), profiles.DataTargets...)
|
||||
for i := range profiles.DataTargets {
|
||||
profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms)
|
||||
}
|
||||
profiles.DLLExtensions = append([]RuntimeDLLExtensionProfile(nil), profiles.DLLExtensions...)
|
||||
for i := range profiles.DLLExtensions {
|
||||
@@ -1997,15 +1876,6 @@ func CopyServerInstance(instance ServerInstance) ServerInstance {
|
||||
return instance
|
||||
}
|
||||
|
||||
func CopyRuntimeServerDeploymentProfile(profile RuntimeServerDeploymentProfile) RuntimeServerDeploymentProfile {
|
||||
profile.Prerequisites = append([]RuntimeServerPrerequisite(nil), profile.Prerequisites...)
|
||||
profile.SupportedTargets = append([]RuntimeTarget(nil), profile.SupportedTargets...)
|
||||
profile.ConfigMappings = append([]RuntimeServerConfigMapping(nil), profile.ConfigMappings...)
|
||||
profile.DiscoveryMarkers = append([]RuntimeServerDiscoveryMarker(nil), profile.DiscoveryMarkers...)
|
||||
profile.VerificationChecks = append([]RuntimeServerVerificationCheck(nil), profile.VerificationChecks...)
|
||||
return profile
|
||||
}
|
||||
|
||||
func CopyServerDeploymentProjection(projection ServerDeploymentProjection) ServerDeploymentProjection {
|
||||
projection.DiscoveredFacts = CopyStringMap(projection.DiscoveredFacts)
|
||||
projection.MappingResults = CopyStringMap(projection.MappingResults)
|
||||
@@ -2246,10 +2116,6 @@ func CopyRunDistribution(distribution RunDistribution) RunDistribution {
|
||||
return distribution
|
||||
}
|
||||
|
||||
func CopyClientManagerDistribution(distribution ClientManagerDistribution) ClientManagerDistribution {
|
||||
return distribution
|
||||
}
|
||||
|
||||
func CopyDependencyStatus(status DependencyStatus) DependencyStatus {
|
||||
return status
|
||||
}
|
||||
@@ -2263,10 +2129,6 @@ func CopyDependencyCatalog(catalog DependencyCatalog) DependencyCatalog {
|
||||
return catalog
|
||||
}
|
||||
|
||||
func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob {
|
||||
return job
|
||||
}
|
||||
|
||||
func CopyRunUpdateJob(job RunUpdateJob) RunUpdateJob {
|
||||
return job
|
||||
}
|
||||
@@ -2275,10 +2137,6 @@ func CopyRunDistributionGenerateRequest(request RunDistributionGenerateRequest)
|
||||
return request
|
||||
}
|
||||
|
||||
func CopyClientManagerBuildRequest(request ClientManagerBuildRequest) ClientManagerBuildRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
func CopyComponentKeyResetRequest(request ComponentKeyResetRequest) ComponentKeyResetRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api
|
||||
|
||||
Remote access jobs are enabled only when both the selected run endpoint reports the capability and the server instance's installed plugin declares it. Plugin pages must use `remote.access.request` with `server.remote.access`; platform rejects undeclared database, RCON, log transfer, or remote file capabilities before creating jobs.
|
||||
|
||||
Runtime profile and distribution permissions are declared by plugins, then gated again by platform routes and services. `server.run.distribution` enables run package generation/download/reset/update operations, `server.dependencies.manage` enables dependency check/install jobs, and `server.client-manager.manage` enables plugin-declared companion client-manager generation/download/reset operations. Plugin metadata stores only declarations and safe refs; raw run/client-manager keys and transport credentials are stored through platform secret resources, never in plugin records.
|
||||
Runtime profile and distribution permissions are declared by plugins, then gated again by platform routes and services. `server.run.distribution` enables run package generation/download/reset/update operations, and `server.dependencies.manage` enables dependency check/install jobs. Plugin metadata stores only declarations and safe refs; raw run keys and transport credentials are stored through platform secret resources, never in plugin records.
|
||||
|
||||
## ServerInstance
|
||||
|
||||
@@ -104,12 +104,12 @@ Run sessions persist only a token hash, generation, status, expiry, capability f
|
||||
- `serverInstanceId`: server instance using the binding.
|
||||
- `pluginId` and `pluginVersion`: installed plugin contract that declared the logical runtime profile.
|
||||
- `profileKey`: declared lifecycle/runtime profile key.
|
||||
- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`.
|
||||
- `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, or `ftp-only`.
|
||||
- `bindings`: logical binding keys to operator-provided settings.
|
||||
- `missingKeys`: logical keys that must be completed before dependent actions are available.
|
||||
- `status`: `complete` or `incomplete`.
|
||||
|
||||
Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, transport, and client-manager declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`.
|
||||
Installed `GamePlugin` records persist the validated manifest `runtimeProfiles` contract, including discovery, lifecycle, dependency/install, log, and transport declarations. One server binding selects one declared lifecycle profile. Platform derives allowed and required logical keys; clients cannot assert `missingKeys` or `status`.
|
||||
|
||||
Plugin lifecycle assets are registered as manifest-declared files plus a
|
||||
content-bearing registration payload. Platform packages those assets into
|
||||
@@ -128,11 +128,9 @@ Bindings are used for action gating and future run-side profile resolution. File
|
||||
|
||||
- `EncryptedComponentKey`: stores exactly one active encrypted key per server/component plus hash, fingerprint, redacted secret ref, generation, status, and reset time.
|
||||
- `RunDistribution`: records a generated run package for one server, target OS/architecture, package format, artifact ID, checksum, key generation, secret ref, and status.
|
||||
- `ClientManagerDistribution`: records a generated plugin-declared client-manager package with profile key, repository/source revision metadata, build job ID, artifact ID, checksum, key generation, secret ref, and status.
|
||||
- `ClientManagerBuildJob`: records source checkout/build status, target platform, artifact ID, checksum, redacted build log ref, key generation, and status.
|
||||
- `RunUpdateJob`: records platform-created Run self-update orchestration with server, endpoint, artifact ID/checksum, target and previous release, job/idempotency identity, `queued/downloading/staged/restart-requested/activating/succeeded/rolled-back/failed` phase, bounded message, rollback flag, and timestamps. Platform only projects success after a signed current-session post-reconciliation health report; terminal staging alone remains `restart-requested`.
|
||||
|
||||
Run and client-manager keys are isolated singleton credentials. Reset replaces the encrypted database value, increments generation, marks older distributions revoked, and requires regenerating and redeploying that component. API DTOs may expose key generation, fingerprint, status, artifact ID, checksum, job ID, and `secret://runtime-keys/.../current` refs, but never the raw key.
|
||||
Run keys are isolated singleton credentials. Reset replaces the encrypted database value, increments generation, marks older distributions revoked, and requires regenerating and redeploying that component. API DTOs may expose key generation, fingerprint, status, artifact ID, checksum, job ID, and `secret://runtime-keys/.../current` refs, but never the raw key.
|
||||
|
||||
## DependencyStatus
|
||||
|
||||
@@ -192,7 +190,3 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti
|
||||
- `latestSeq`: latest accepted sequence.
|
||||
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
|
||||
- `retentionPolicy`: retention key.
|
||||
|
||||
# Client Manager lifecycle aggregates
|
||||
|
||||
`ClientManagerInstallation` owns desired/active/previous artifact and version metadata, target and key/deployment generations, the current job, logical health/last-seen projection, retry/fencing flags, and uninstall history. Valid statuses are `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled`. `ClientManagerSession` is a separate short-lived component identity bound to installation, endpoint, artifact, key generation, and deployment generation; it is not a Run session or lease.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCopyGamePluginRuntimeProfilesCopiesLogEvents(t *testing.T) {
|
||||
profiles := GamePluginRuntimeProfiles{
|
||||
LogEvents: []RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
copied := CopyGamePluginRuntimeProfiles(profiles)
|
||||
copied.LogEvents[0].Title = "Mutated"
|
||||
|
||||
if profiles.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatalf("expected log event declarations to be copied, got %+v", profiles.LogEvents)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
SCUMUserOfflineAfter = 15 * time.Minute
|
||||
SCUMWelcomeMessageDelay = time.Minute
|
||||
SCUMDefaultListLimit = 500
|
||||
SCUMDefaultTrajectoryLimit = 500
|
||||
SCUMFactBatchLimit = 1000
|
||||
)
|
||||
|
||||
type SCUMPosition struct {
|
||||
X float64
|
||||
Y float64
|
||||
Z float64
|
||||
}
|
||||
|
||||
type SCUMUser struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
SteamID string
|
||||
DisplayName string
|
||||
SquadID int64
|
||||
RegisteredAt time.Time
|
||||
LastLoginAt time.Time
|
||||
LastActivityAt time.Time
|
||||
LastLoginIP string
|
||||
Online bool
|
||||
X *float64
|
||||
Y *float64
|
||||
Z *float64
|
||||
BankBalance *int64
|
||||
GoldBars *int64
|
||||
RiddenVehicleID string
|
||||
GameVehicleID string
|
||||
LastLoginAge time.Duration
|
||||
WelcomeQueuedAt time.Time
|
||||
WelcomeLastError string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMUserFilter struct {
|
||||
ServerInstanceID string
|
||||
SteamID string
|
||||
Online *bool
|
||||
ChangedAfter time.Time
|
||||
// StaleBefore matches users whose latest activity is older than the given
|
||||
// instant. It backs the offline convergence query for stale online users.
|
||||
StaleBefore time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SCUMUserTrajectory struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
SCUMUserID string
|
||||
SteamID string
|
||||
DisplayName string
|
||||
X float64
|
||||
Y float64
|
||||
Z float64
|
||||
RiddenVehicleID string
|
||||
GameVehicleID string
|
||||
ObservedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMUserTrajectoryFilter struct {
|
||||
ServerInstanceID string
|
||||
SCUMUserID string
|
||||
SteamID string
|
||||
After time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SCUMVehicle struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
GameVehicleID string
|
||||
VehicleClass string
|
||||
DisplayName string
|
||||
Exists bool
|
||||
Locked bool
|
||||
Functional *bool
|
||||
X *float64
|
||||
Y *float64
|
||||
Z *float64
|
||||
LastObservedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMVehicleFilter struct {
|
||||
ServerInstanceID string
|
||||
GameVehicleID string
|
||||
Exists *bool
|
||||
ChangedAfter time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SCUMVehicleTrajectory struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
SCUMVehicleID string
|
||||
GameVehicleID string
|
||||
VehicleClass string
|
||||
X float64
|
||||
Y float64
|
||||
Z float64
|
||||
ObservedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMVehicleTrajectoryFilter struct {
|
||||
ServerInstanceID string
|
||||
SCUMVehicleID string
|
||||
GameVehicleID string
|
||||
After time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SCUMVehicleLock struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
SCUMVehicleID string
|
||||
GameVehicleID string
|
||||
SCUMUserID string
|
||||
SteamID string
|
||||
LockedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMVehicleLockFilter struct {
|
||||
ServerInstanceID string
|
||||
SCUMVehicleID string
|
||||
GameVehicleID string
|
||||
SCUMUserID string
|
||||
SteamID string
|
||||
After time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SCUMSurface struct {
|
||||
Users []SCUMUser
|
||||
Vehicles []SCUMVehicle
|
||||
UserTrajectories []SCUMUserTrajectory
|
||||
VehicleTrajectories []SCUMVehicleTrajectory
|
||||
VehicleLocks []SCUMVehicleLock
|
||||
}
|
||||
|
||||
type SCUMFactIngest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
ServerInstanceID string
|
||||
Users []SCUMUserFact
|
||||
Vehicles []SCUMVehicleFact
|
||||
}
|
||||
|
||||
type SCUMUserFact struct {
|
||||
SteamID string
|
||||
DisplayName string
|
||||
SquadID *int64
|
||||
LoginIP string
|
||||
Online bool
|
||||
Login bool
|
||||
ObservedAt time.Time
|
||||
LoginObservedAt time.Time
|
||||
Position *SCUMPosition
|
||||
BankBalance *int64
|
||||
GoldBars *int64
|
||||
RiddenGameVehicleID string
|
||||
}
|
||||
|
||||
type SCUMVehicleFact struct {
|
||||
GameVehicleID string
|
||||
VehicleClass string
|
||||
DisplayName string
|
||||
Exists *bool
|
||||
Locked *bool
|
||||
Functional *bool
|
||||
LockedBySteamID string
|
||||
LockedAt time.Time
|
||||
ObservedAt time.Time
|
||||
Position *SCUMPosition
|
||||
}
|
||||
|
||||
type SCUMFactIngestResult struct {
|
||||
Accepted bool
|
||||
AcceptedUserCount int
|
||||
AcceptedVehicleCount int
|
||||
WelcomeQueuedCount int
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
func CopySCUMUser(value SCUMUser) SCUMUser {
|
||||
value.X = copyFloatPtr(value.X)
|
||||
value.Y = copyFloatPtr(value.Y)
|
||||
value.Z = copyFloatPtr(value.Z)
|
||||
value.BankBalance = copyInt64Ptr(value.BankBalance)
|
||||
value.GoldBars = copyInt64Ptr(value.GoldBars)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMUsers(values []SCUMUser) []SCUMUser {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMUser, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = CopySCUMUser(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMUserTrajectory(value SCUMUserTrajectory) SCUMUserTrajectory { return value }
|
||||
func CopySCUMUserTrajectories(values []SCUMUserTrajectory) []SCUMUserTrajectory {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMUserTrajectory, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
|
||||
value.Functional = copyBoolPtr(value.Functional)
|
||||
value.X = copyFloatPtr(value.X)
|
||||
value.Y = copyFloatPtr(value.Y)
|
||||
value.Z = copyFloatPtr(value.Z)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMVehicles(values []SCUMVehicle) []SCUMVehicle {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMVehicle, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = CopySCUMVehicle(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMVehicleTrajectory(value SCUMVehicleTrajectory) SCUMVehicleTrajectory { return value }
|
||||
func CopySCUMVehicleTrajectories(values []SCUMVehicleTrajectory) []SCUMVehicleTrajectory {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMVehicleTrajectory, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMVehicleLock(value SCUMVehicleLock) SCUMVehicleLock { return value }
|
||||
func CopySCUMVehicleLocks(values []SCUMVehicleLock) []SCUMVehicleLock {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMVehicleLock, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMFactIngest(value SCUMFactIngest) SCUMFactIngest {
|
||||
value.Users = CopySCUMUserFacts(value.Users)
|
||||
value.Vehicles = CopySCUMVehicleFacts(value.Vehicles)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMUserFacts(values []SCUMUserFact) []SCUMUserFact {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMUserFact, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = value
|
||||
out[i].SquadID = copyInt64Ptr(value.SquadID)
|
||||
out[i].BankBalance = copyInt64Ptr(value.BankBalance)
|
||||
out[i].GoldBars = copyInt64Ptr(value.GoldBars)
|
||||
if value.Position != nil {
|
||||
position := *value.Position
|
||||
out[i].Position = &position
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CopySCUMVehicleFacts(values []SCUMVehicleFact) []SCUMVehicleFact {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]SCUMVehicleFact, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = value
|
||||
out[i].Exists = copyBoolPtr(value.Exists)
|
||||
out[i].Locked = copyBoolPtr(value.Locked)
|
||||
out[i].Functional = copyBoolPtr(value.Functional)
|
||||
if value.Position != nil {
|
||||
position := *value.Position
|
||||
out[i].Position = &position
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyInt64Ptr(value *int64) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func copyBoolPtr(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package domain
|
||||
type ServerLifecycleAction string
|
||||
|
||||
const (
|
||||
ServerLifecycleActionCreate ServerLifecycleAction = "create"
|
||||
ServerLifecycleActionStart ServerLifecycleAction = "start"
|
||||
ServerLifecycleActionStop ServerLifecycleAction = "stop"
|
||||
ServerLifecycleActionStatus ServerLifecycleAction = "status"
|
||||
ServerLifecycleActionCreate ServerLifecycleAction = "create"
|
||||
ServerLifecycleActionStart ServerLifecycleAction = "start"
|
||||
ServerLifecycleActionStop ServerLifecycleAction = "stop"
|
||||
ServerLifecycleActionRestart ServerLifecycleAction = "restart"
|
||||
ServerLifecycleActionUpdate ServerLifecycleAction = "update"
|
||||
ServerLifecycleActionStatus ServerLifecycleAction = "status"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -48,8 +50,10 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
|
||||
return LifecycleCapabilityInstall
|
||||
case ServerLifecycleActionStart:
|
||||
return LifecycleCapabilityStart
|
||||
case ServerLifecycleActionStop:
|
||||
case ServerLifecycleActionStop, ServerLifecycleActionRestart:
|
||||
return LifecycleCapabilityStop
|
||||
case ServerLifecycleActionUpdate:
|
||||
return LifecycleCapabilityInstall
|
||||
case ServerLifecycleActionStatus:
|
||||
return LifecycleCapabilityStatus
|
||||
default:
|
||||
|
||||
@@ -22,9 +22,10 @@ type LlmConfigSuggestionRequest struct {
|
||||
}
|
||||
|
||||
type LlmConfigSuggestionResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||
}
|
||||
|
||||
type AIInvocationUsageResponse struct {
|
||||
@@ -39,8 +40,11 @@ type AIConfigRecommendationResponse struct {
|
||||
Key string `json:"key"`
|
||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
DiffID string `json:"diffId,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type AIConfigExecutionResponse struct {
|
||||
Status string `json:"status"`
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type AIInvocationSafeErrorResponse struct {
|
||||
@@ -57,6 +61,7 @@ type AIInvocationResponse struct {
|
||||
Status string `json:"status"`
|
||||
Recommendation string `json:"recommendation,omitempty"`
|
||||
ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"`
|
||||
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||
Usage AIInvocationUsageResponse `json:"usage"`
|
||||
Error *AIInvocationSafeErrorResponse `json:"error,omitempty"`
|
||||
}
|
||||
@@ -84,10 +89,12 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
||||
Key: response.ConfigRecommendation.Key,
|
||||
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
||||
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
||||
DiffID: response.ConfigRecommendation.DiffID,
|
||||
ExpiresAt: response.ConfigRecommendation.ExpiresAt,
|
||||
}
|
||||
}
|
||||
var execution *AIConfigExecutionResponse
|
||||
if response.ConfigExecution != nil {
|
||||
execution = &AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: JobFromDomain(response.ConfigExecution.Job)}
|
||||
}
|
||||
var safeError *AIInvocationSafeErrorResponse
|
||||
if response.Error != nil {
|
||||
safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||
@@ -100,6 +107,7 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
||||
Status: response.Status,
|
||||
Recommendation: response.Recommendation,
|
||||
ConfigRecommendation: config,
|
||||
ConfigExecution: execution,
|
||||
Usage: AIInvocationUsageResponse{
|
||||
ProviderID: response.Usage.ProviderID,
|
||||
Model: response.Usage.Model,
|
||||
|
||||
@@ -42,7 +42,7 @@ type ArtifactChunkUploadRequest struct {
|
||||
Offset int64 `json:"offset"`
|
||||
SizeBytes int `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Payload []byte `json:"payload"`
|
||||
Payload []byte `json:"-"`
|
||||
}
|
||||
|
||||
type ArtifactChunkUploadResponse struct {
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type ClientManagerDeployRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
DistributionID string `json:"distributionId"`
|
||||
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ClientManagerControlRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Operation string `json:"operation"`
|
||||
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ClientManagerUpdateRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
DistributionID string `json:"distributionId"`
|
||||
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
|
||||
Approved bool `json:"approved"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ClientManagerUninstallRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ClientManagerRetryRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ClientManagerRevokeSessionRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleActionResponse struct {
|
||||
Operation string `json:"operation"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleJobResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
State domain.JobState `json:"state,omitempty"`
|
||||
Progress JobProgressBody `json:"progress,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerInstallationResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
DesiredVersion string `json:"desiredVersion,omitempty"`
|
||||
ActiveVersion string `json:"activeVersion,omitempty"`
|
||||
PreviousVersion string `json:"previousVersion,omitempty"`
|
||||
DesiredRevision string `json:"desiredRevision,omitempty"`
|
||||
ActiveRevision string `json:"activeRevision,omitempty"`
|
||||
PreviousRevision string `json:"previousRevision,omitempty"`
|
||||
DesiredArtifactID string `json:"desiredArtifactId,omitempty"`
|
||||
ActiveArtifactID string `json:"activeArtifactId,omitempty"`
|
||||
PreviousArtifactID string `json:"previousArtifactId,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
CurrentJobID string `json:"currentJobId,omitempty"`
|
||||
LastSuccessfulJobID string `json:"lastSuccessfulJobId,omitempty"`
|
||||
LastOperation string `json:"lastOperation,omitempty"`
|
||||
Health string `json:"health"`
|
||||
HealthReason string `json:"healthReason,omitempty"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RequiresRedeploy bool `json:"requiresRedeploy"`
|
||||
InstalledAt time.Time `json:"installedAt,omitempty"`
|
||||
UninstalledAt time.Time `json:"uninstalledAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Distribution *ClientManagerDistributionSummaryResponse `json:"distribution,omitempty"`
|
||||
Job *ClientManagerLifecycleJobResponse `json:"job,omitempty"`
|
||||
Actions []ClientManagerLifecycleActionResponse `json:"actions"`
|
||||
}
|
||||
|
||||
type ClientManagerDistributionSummaryResponse struct {
|
||||
ID string `json:"id"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
Checksum string `json:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ClientManagerInstallationListResponse struct {
|
||||
Items []ClientManagerInstallationResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type ClientManagerLifecycleInputResponse struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Operation string `json:"operation"`
|
||||
ArtifactID string `json:"artifactId,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
ExecutableRef string `json:"executableRef"`
|
||||
Arguments []string `json:"arguments"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
|
||||
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
||||
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
CompanionConfig *ClientManagerCompanionConfigInputResponse `json:"companionConfig,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerCompanionConfigInputResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||
ConfigTemplateRef string `json:"configTemplateRef"`
|
||||
ConfigOutputRef string `json:"configOutputRef"`
|
||||
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
RegistrationProof string `json:"registrationProof"`
|
||||
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||
SessionMode string `json:"sessionMode"`
|
||||
TLSPolicy string `json:"tlsPolicy"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type ClientManagerRegisterRequest struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Nonce string `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type ClientManagerRegisterResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
InstallationID string `json:"installationId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
HeartbeatEvery int `json:"heartbeatEverySeconds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ClientManagerHeartbeatRequest struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
Health string `json:"health"`
|
||||
HealthReason string `json:"healthReason,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
}
|
||||
|
||||
type ClientManagerHeartbeatResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
InstallationID string `json:"installationId"`
|
||||
Status string `json:"status"`
|
||||
Health string `json:"health"`
|
||||
NextHeartbeat int `json:"nextHeartbeatSeconds"`
|
||||
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
func (request ClientManagerDeployRequest) ToDomain(serverID string) domain.ClientManagerDeployRequest {
|
||||
return domain.ClientManagerDeployRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ClientManagerControlRequest) ToDomain(serverID string) domain.ClientManagerControlRequest {
|
||||
return domain.ClientManagerControlRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Operation: domain.ClientManagerLifecycleOperation(request.Operation), ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ClientManagerUpdateRequest) ToDomain(serverID string) domain.ClientManagerUpdateRequest {
|
||||
return domain.ClientManagerUpdateRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Approved: request.Approved, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ClientManagerUninstallRequest) ToDomain(serverID string) domain.ClientManagerUninstallRequest {
|
||||
return domain.ClientManagerUninstallRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Confirmed: request.Confirmed, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ClientManagerRetryRequest) ToDomain(serverID string) domain.ClientManagerRetryRequest {
|
||||
return domain.ClientManagerRetryRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func (request ClientManagerRevokeSessionRequest) ToDomain(serverID string) domain.ClientManagerRevokeSessionRequest {
|
||||
return domain.ClientManagerRevokeSessionRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Reason: request.Reason}
|
||||
}
|
||||
|
||||
func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManagerLifecycleInputRequest {
|
||||
return domain.ClientManagerLifecycleInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
|
||||
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
|
||||
value = domain.CopyClientManagerLifecycleInput(value)
|
||||
response := ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
|
||||
if value.CompanionConfig != nil {
|
||||
companion := ClientManagerCompanionConfigInputFromDomain(*value.CompanionConfig)
|
||||
response.CompanionConfig = &companion
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func ClientManagerCompanionConfigInputFromDomain(value domain.ClientManagerCompanionConfigInput) ClientManagerCompanionConfigInputResponse {
|
||||
value = domain.CopyClientManagerCompanionConfigInput(value)
|
||||
return ClientManagerCompanionConfigInputResponse{SchemaVersion: value.SchemaVersion, ConfigTemplateKey: value.ConfigTemplateKey, ConfigTemplateRef: value.ConfigTemplateRef, ConfigOutputRef: value.ConfigOutputRef, ConfigSchemaRef: value.ConfigSchemaRef, ConfigFormat: value.ConfigFormat, PlatformBaseURLSource: value.PlatformBaseURLSource, InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, ProfileKey: value.ProfileKey, ArtifactID: value.ArtifactID, Version: value.Version, SourceRevision: value.SourceRevision, TargetOS: value.TargetOS, TargetArch: value.TargetArch, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, Capabilities: value.Capabilities, RegistrationProof: value.RegistrationProof, ProofMaterialSource: value.ProofMaterialSource, ProofMaterialEnv: value.ProofMaterialEnv, SessionMode: value.SessionMode, TLSPolicy: value.TLSPolicy, HeartbeatIntervalSeconds: value.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.RequestTimeoutSeconds}
|
||||
}
|
||||
|
||||
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
|
||||
return domain.ClientManagerRegisterRequest{InstallationID: request.InstallationID, ServerInstanceID: request.ServerInstanceID, ProfileKey: request.ProfileKey, ArtifactID: request.ArtifactID, Version: request.Version, SourceRevision: request.SourceRevision, TargetOS: request.TargetOS, TargetArch: request.TargetArch, KeyGeneration: request.KeyGeneration, DeploymentGeneration: request.DeploymentGeneration, Capabilities: domain.CopyStringSlice(request.Capabilities), Timestamp: request.Timestamp, Nonce: request.Nonce, Signature: request.Signature}
|
||||
}
|
||||
|
||||
func ClientManagerRegisterFromDomain(value domain.ClientManagerRegisterResult) ClientManagerRegisterResponse {
|
||||
return ClientManagerRegisterResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, SessionToken: value.SessionToken, ExpiresAt: value.ExpiresAt, HeartbeatEvery: value.HeartbeatEvery, ServerTime: value.ServerTime}
|
||||
}
|
||||
|
||||
func (request ClientManagerHeartbeatRequest) ToDomain() domain.ClientManagerHeartbeat {
|
||||
return domain.ClientManagerHeartbeat{InstallationID: request.InstallationID, SessionToken: request.SessionToken, Sequence: request.Sequence, Health: domain.ClientManagerHealthStatus(request.Health), HealthReason: request.HealthReason, Capabilities: domain.CopyStringSlice(request.Capabilities), SentAt: request.SentAt}
|
||||
}
|
||||
|
||||
func ClientManagerHeartbeatFromDomain(value domain.ClientManagerHeartbeatResult) ClientManagerHeartbeatResponse {
|
||||
return ClientManagerHeartbeatResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, Status: string(value.Status), Health: string(value.Health), NextHeartbeat: value.NextHeartbeat, SessionExpiresAt: value.SessionExpiresAt, ServerTime: value.ServerTime}
|
||||
}
|
||||
|
||||
func ClientManagerLifecycleViewFromDomain(value domain.ClientManagerLifecycleView) ClientManagerInstallationResponse {
|
||||
value = domain.CopyClientManagerLifecycleView(value)
|
||||
installation := value.Installation
|
||||
response := ClientManagerInstallationResponse{ID: installation.ID, ServerInstanceID: installation.ServerInstanceID, PluginID: installation.PluginID, ProfileKey: installation.ProfileKey, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Status: string(installation.Status), Phase: installation.Phase, DesiredVersion: installation.DesiredVersion, ActiveVersion: installation.ActiveVersion, PreviousVersion: installation.PreviousVersion, DesiredRevision: installation.DesiredRevision, ActiveRevision: installation.ActiveRevision, PreviousRevision: installation.PreviousRevision, DesiredArtifactID: installation.DesiredArtifactID, ActiveArtifactID: installation.ActiveArtifactID, PreviousArtifactID: installation.PreviousArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, CurrentJobID: installation.CurrentJobID, LastSuccessfulJobID: installation.LastSuccessfulJobID, LastOperation: string(installation.LastOperation), Health: string(installation.Health), HealthReason: installation.HealthReason, LastSeenAt: installation.LastSeenAt, Retryable: installation.Retryable, RequiresRedeploy: installation.RequiresRedeploy, InstalledAt: installation.InstalledAt, UninstalledAt: installation.UninstalledAt, UpdatedAt: installation.UpdatedAt}
|
||||
if value.Distribution.ID != "" {
|
||||
response.Distribution = &ClientManagerDistributionSummaryResponse{ID: value.Distribution.ID, ArtifactID: value.Distribution.ArtifactID, SourceRevision: value.Distribution.SourceRevision, TargetOS: value.Distribution.TargetOS, TargetArch: value.Distribution.TargetArch, Checksum: value.Distribution.Checksum, KeyGeneration: value.Distribution.KeyGeneration, Status: string(value.Distribution.Status)}
|
||||
}
|
||||
if value.Job.ID != "" {
|
||||
response.Job = &ClientManagerLifecycleJobResponse{ID: value.Job.ID, State: value.Job.State, Progress: JobProgressBody{Percent: value.Job.Progress.Percent, Message: value.Job.Progress.Message}, Attempt: value.Job.Attempt, CreatedAt: value.Job.CreatedAt, UpdatedAt: value.Job.UpdatedAt}
|
||||
}
|
||||
response.Actions = make([]ClientManagerLifecycleActionResponse, len(value.Actions))
|
||||
for i, action := range value.Actions {
|
||||
response.Actions[i] = ClientManagerLifecycleActionResponse{Operation: string(action.Operation), Available: action.Available, Reason: action.Reason}
|
||||
}
|
||||
if response.Actions == nil {
|
||||
response.Actions = []ClientManagerLifecycleActionResponse{}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func ClientManagerLifecycleViewsFromDomain(values []domain.ClientManagerLifecycleView) ClientManagerInstallationListResponse {
|
||||
items := make([]ClientManagerInstallationResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = ClientManagerLifecycleViewFromDomain(value)
|
||||
}
|
||||
return ClientManagerInstallationListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleInputProjectsRunOnlyCompanionConfigWithoutRawMaterial(t *testing.T) {
|
||||
response := ClientManagerLifecycleInputFromDomain(domain.ClientManagerLifecycleInput{
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
ProfileKey: "scum-client-manager",
|
||||
Operation: domain.ClientManagerOperationDeploy,
|
||||
DeploymentGeneration: 2,
|
||||
CompanionConfig: &domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: 1,
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigTemplateRef: "config.yaml.example",
|
||||
ConfigOutputRef: "config.yaml",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
ProfileKey: "scum-client-manager",
|
||||
ArtifactID: "artifact-1",
|
||||
Version: "1.0.0",
|
||||
SourceRevision: "revision-1",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
KeyGeneration: 3,
|
||||
DeploymentGeneration: 2,
|
||||
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
})
|
||||
if response.CompanionConfig == nil || response.CompanionConfig.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" {
|
||||
t.Fatalf("expected run-only companion projection, got %+v", response.CompanionConfig)
|
||||
}
|
||||
encoded, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal lifecycle input: %v", err)
|
||||
}
|
||||
serialized := string(encoded)
|
||||
for _, forbidden := range []string{`"proofMaterial":`, `"authKey":`, `"componentKey":`, `"sessionToken":`, `"secretRef":`, "/Users/", "run.socket"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("run lifecycle input leaked %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
|
||||
browserProjection, err := json.Marshal(ClientManagerLifecycleViewFromDomain(domain.ClientManagerLifecycleView{Installation: domain.ClientManagerInstallation{ID: "installation-1"}}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal browser lifecycle projection: %v", err)
|
||||
}
|
||||
if strings.Contains(string(browserProjection), "companionConfig") || strings.Contains(string(browserProjection), "proofMaterialEnv") {
|
||||
t.Fatalf("browser lifecycle projection exposed run-only config: %s", browserProjection)
|
||||
}
|
||||
}
|
||||
@@ -80,19 +80,6 @@ type LogBackfillRequest struct {
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerDownloadRequest struct {
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
}
|
||||
|
||||
type ClientManagerBuildRequest struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
RepositoryURL string `json:"repositoryUrl"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
|
||||
type ComponentKeyResetRequest struct {
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ComponentKey string `json:"componentKey,omitempty"`
|
||||
@@ -145,26 +132,6 @@ type RunDistributionResponse struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ClientManagerDistributionResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Version string `json:"version,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
RepositoryURL string `json:"repositoryUrl"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
BuildJobID string `json:"buildJobId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Checksum string `json:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DependencyStatusResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
@@ -225,25 +192,6 @@ type DependencyCatalogResponse struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ClientManagerBuildJobResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Version string `json:"version,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
RepositoryURL string `json:"repositoryUrl"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
ArtifactID string `json:"artifactId,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration,omitempty"`
|
||||
LogsRef string `json:"logsRef,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RunUpdateJobResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
@@ -310,18 +258,6 @@ func (request LogBackfillRequest) ToDomain(serverInstanceID string) domain.LogBa
|
||||
}
|
||||
}
|
||||
|
||||
func (request ClientManagerBuildRequest) ToDomain(serverInstanceID string) domain.ClientManagerBuildRequest {
|
||||
return domain.ClientManagerBuildRequest{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (request ComponentKeyResetRequest) ToDomain(serverInstanceID string) domain.ComponentKeyResetRequest {
|
||||
return domain.ComponentKeyResetRequest{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
@@ -381,28 +317,6 @@ func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribut
|
||||
}
|
||||
}
|
||||
|
||||
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistributionResponse {
|
||||
return ClientManagerDistributionResponse{
|
||||
ID: distribution.ID,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
Version: distribution.Version,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
BuildJobID: distribution.BuildJobID,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
Checksum: distribution.Checksum,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
SecretRef: distribution.SecretRef,
|
||||
Status: string(distribution.Status),
|
||||
CreatedAt: distribution.CreatedAt,
|
||||
UpdatedAt: distribution.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatusResponse {
|
||||
return DependencyStatusResponse{
|
||||
ID: status.ID,
|
||||
@@ -441,27 +355,6 @@ func DependencyCatalogFromDomain(catalog domain.DependencyCatalog) DependencyCat
|
||||
return DependencyCatalogResponse{ServerInstanceID: catalog.ServerInstanceID, PluginID: catalog.PluginID, PluginVersion: catalog.PluginVersion, ProfileKey: catalog.ProfileKey, TargetOS: catalog.TargetOS, TargetArch: catalog.TargetArch, Probes: probes, Plans: plans, UpdatedAt: catalog.UpdatedAt}
|
||||
}
|
||||
|
||||
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJobResponse {
|
||||
return ClientManagerBuildJobResponse{
|
||||
ID: job.ID,
|
||||
ServerInstanceID: job.ServerInstanceID,
|
||||
PluginID: job.PluginID,
|
||||
ProfileKey: job.ProfileKey,
|
||||
Version: job.Version,
|
||||
TargetOS: job.TargetOS,
|
||||
TargetArch: job.TargetArch,
|
||||
RepositoryURL: job.RepositoryURL,
|
||||
SourceRevision: job.SourceRevision,
|
||||
ArtifactID: job.ArtifactID,
|
||||
Checksum: job.Checksum,
|
||||
KeyGeneration: job.KeyGeneration,
|
||||
LogsRef: job.LogsRef,
|
||||
Status: string(job.Status),
|
||||
CreatedAt: job.CreatedAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse {
|
||||
return RunUpdateJobResponse{
|
||||
ID: job.ID,
|
||||
|
||||
@@ -45,6 +45,10 @@ type GameClientBridgeSnapshotIngestRequest struct {
|
||||
MaxRecords int `json:"maxRecords,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogStreamRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCancelRequest struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
@@ -228,6 +232,10 @@ func (request GameClientBridgeSnapshotIngestRequest) ToDomain() domain.GameClien
|
||||
return domain.GameClientBridgeSnapshotIngestRequest{SessionToken: request.SessionToken, Type: request.Type, SchemaVersion: request.SchemaVersion, StreamKey: request.StreamKey, Sequence: request.Sequence, ObservedAt: request.ObservedAt, Payload: domain.CopyGameClientBridgePayload(request.Payload), Retention: domain.GameClientBridgeRetention{KeepForSeconds: request.KeepForSeconds, MaxRecords: request.MaxRecords}}
|
||||
}
|
||||
|
||||
func (request GameClientBridgeLogStreamRequest) ToDomain() domain.GameClientBridgeLogStreamRequest {
|
||||
return domain.GameClientBridgeLogStreamRequest{SessionToken: request.SessionToken}
|
||||
}
|
||||
|
||||
func (query GameClientBridgeSnapshotQuery) ToDomain(serverID, pluginID string) domain.GameClientBridgeSnapshotQuery {
|
||||
return domain.GameClientBridgeSnapshotQuery{ServerInstanceID: serverID, PluginID: pluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: query.Limit}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGameClientBridgeCompanionDeclarationRoundTripsWithoutMaterial(t *testing.T) {
|
||||
body := GameClientBridgeManifestBody{
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
Companion: &GameClientBridgeCompanionDeclarationBody{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
}
|
||||
domainValue := body.ToDomain()
|
||||
if domainValue.Companion.ProfileKey != "scum-client-manager" || domainValue.Companion.TLSPolicy != "verify-system-roots" {
|
||||
t.Fatalf("companion declaration conversion lost fields: %+v", domainValue.Companion)
|
||||
}
|
||||
projection := gameClientBridgeManifestFromDomain(domainValue)
|
||||
encoded, err := json.Marshal(projection)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal companion declaration: %v", err)
|
||||
}
|
||||
serialized := string(encoded)
|
||||
for _, forbidden := range []string{"authKey", "componentKey", "sessionToken", "credential", "secretRef", "hostPath", "runSocket"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("companion declaration exposed %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||
func TestGameClientBridgeListResponseConversions(t *testing.T) {
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
command := domain.GameClientBridgeCommand{
|
||||
ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded,
|
||||
@@ -95,7 +95,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) {
|
||||
claim.Items[0].Payload["scope"] = "changed"
|
||||
result.Result.Payload["healthy"] = false
|
||||
if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true {
|
||||
t.Fatal("companion response aliases domain payload")
|
||||
t.Fatal("bridge response aliases domain payload")
|
||||
}
|
||||
for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots} {
|
||||
encoded, err := json.Marshal(value)
|
||||
|
||||
@@ -96,6 +96,7 @@ type RunJobResultRequest struct {
|
||||
type RunJobExecutionInputBody struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
FileTargetKey string `json:"fileTargetKey,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
@@ -248,6 +249,7 @@ type DependencyExecutionInputResponse struct {
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
PlanDigest string `json:"planDigest"`
|
||||
ServerRoot string `json:"serverRoot,omitempty"`
|
||||
Probe RuntimeDependencyProbeBody `json:"probe,omitempty"`
|
||||
Plan RuntimeInstallPlanBody `json:"plan,omitempty"`
|
||||
Bindings map[string]string `json:"bindings"`
|
||||
@@ -579,7 +581,7 @@ func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) D
|
||||
for i, step := range input.Plan.Steps {
|
||||
steps[i] = RuntimeInstallStepBody{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum}
|
||||
}
|
||||
return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings}
|
||||
return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, ServerRoot: input.ServerRoot, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, SteamAppID: input.Probe.SteamAppID, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings}
|
||||
}
|
||||
|
||||
func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) SourceRCONExecutionInputResponse {
|
||||
@@ -662,7 +664,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
State: assignment.State,
|
||||
Progress: progressReportFromDomain(assignment.Progress),
|
||||
ResultRef: assignment.ResultRef,
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, FileTargetKey: assignment.ExecutionInput.FileTargetKey, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
FencingToken: assignment.FencingToken,
|
||||
|
||||
@@ -12,7 +12,6 @@ type LogEntryBody struct {
|
||||
Level string `json:"level,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Redacted bool `json:"redacted"`
|
||||
}
|
||||
|
||||
type LogBatchIngestRequest struct {
|
||||
@@ -178,7 +177,6 @@ func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
|
||||
Level: entry.Level,
|
||||
Line: entry.Line,
|
||||
Fields: copyStringMap(entry.Fields),
|
||||
Redacted: entry.Redacted,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -191,7 +189,6 @@ func logEntryFromDomain(entry domain.LogEntry) LogEntryBody {
|
||||
Level: entry.Level,
|
||||
Line: entry.Line,
|
||||
Fields: copyStringMap(entry.Fields),
|
||||
Redacted: entry.Redacted,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,44 +42,6 @@ type PluginLifecycleActionResponse struct {
|
||||
Job JobResponse `json:"job"`
|
||||
}
|
||||
|
||||
type AIConfigDiffPreviewResponse struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"requestId"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
ProviderID string `json:"providerId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Key string `json:"key"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum,omitempty"`
|
||||
ProposedConfig string `json:"proposedConfig,omitempty"`
|
||||
DiffSummary string `json:"diffSummary"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AIConfigDiffListResponse struct {
|
||||
Items []AIConfigDiffPreviewResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalRequest struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type AIConfigDiffApprovalResponse struct {
|
||||
Preview AIConfigDiffPreviewResponse `json:"preview"`
|
||||
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
||||
}
|
||||
|
||||
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
||||
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
@@ -101,25 +63,3 @@ func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) Plugin
|
||||
result = domain.CopyPluginLifecycleResult(result)
|
||||
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
||||
}
|
||||
|
||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
||||
preview = domain.CopyAIConfigDiffPreview(preview)
|
||||
return AIConfigDiffPreviewResponse{ID: preview.ID, RequestID: preview.RequestID, CreatedBy: preview.CreatedBy, ServerInstanceID: preview.ServerInstanceID, PluginID: preview.PluginID, ProviderID: preview.ProviderID, Model: preview.Model, Key: preview.Key, ConfigVersion: preview.ConfigVersion, CurrentConfigChecksum: preview.CurrentConfigChecksum, ProposedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, State: string(preview.State), ExpiresAt: preview.ExpiresAt, ApprovedBy: preview.ApprovedBy, ApprovedAt: preview.ApprovedAt, CancelledBy: preview.CancelledBy, CancelledAt: preview.CancelledAt, JobID: preview.JobID, CreatedAt: preview.CreatedAt, UpdatedAt: preview.UpdatedAt}
|
||||
}
|
||||
|
||||
func AIConfigDiffListFromDomain(previews []domain.AIConfigDiffPreview) AIConfigDiffListResponse {
|
||||
items := make([]AIConfigDiffPreviewResponse, len(previews))
|
||||
for i, preview := range previews {
|
||||
items[i] = AIConfigDiffFromDomain(preview)
|
||||
}
|
||||
return AIConfigDiffListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func (request AIConfigDiffApprovalRequest) ToDomain(diffID string) domain.AIConfigDiffApprovalRequest {
|
||||
return domain.AIConfigDiffApprovalRequest{DiffID: diffID, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AIConfigDiffApprovalResponse {
|
||||
result = domain.CopyAIConfigDiffApprovalResult(result)
|
||||
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
||||
}
|
||||
|
||||
+57
-173
@@ -198,6 +198,7 @@ type PluginLogicalFileBody struct {
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"`
|
||||
StreamKey string `json:"streamKey,omitempty"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
Editable bool `json:"editable,omitempty"`
|
||||
}
|
||||
type PluginConfigFieldBody struct {
|
||||
@@ -242,9 +243,8 @@ type GamePluginManifestServerBody struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestAIBody struct {
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Mediation string `json:"mediation,omitempty"`
|
||||
ConfigWritePolicy string `json:"configWritePolicy,omitempty"`
|
||||
Purposes []string `json:"purposes,omitempty"`
|
||||
Mediation string `json:"mediation,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginProductionLifecycleBody struct {
|
||||
@@ -306,25 +306,6 @@ type GameClientBridgeQueryProjectionDeclarationBody struct {
|
||||
MergeExisting bool `json:"mergeExisting,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionStepDeclarationBody struct {
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionTargetDeclarationBody struct {
|
||||
Collection string `json:"collection"`
|
||||
UpsertKeys []string `json:"upsertKeys"`
|
||||
CaptureMappings map[string]string `json:"captureMappings"`
|
||||
HashMappings map[string]string `json:"hashMappings,omitempty"`
|
||||
FixedValues map[string]string `json:"fixedValues,omitempty"`
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
|
||||
TimestampField string `json:"timestampField"`
|
||||
ActiveWindowSeconds int `json:"activeWindowSeconds"`
|
||||
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLifecycleProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
@@ -349,20 +330,9 @@ type GameClientBridgeBulkActivityTargetBody struct {
|
||||
ObservedAtField string `json:"observedAtField,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeLogProjectionDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
StreamKeys []string `json:"streamKeys"`
|
||||
Steps []GameClientBridgeLogProjectionStepDeclarationBody `json:"steps"`
|
||||
CorrelationFields []string `json:"correlationFields"`
|
||||
MaxInterveningLines int `json:"maxInterveningLines"`
|
||||
Target GameClientBridgeLogProjectionTargetDeclarationBody `json:"target"`
|
||||
Presence *GameClientBridgeLogProjectionPresenceDeclarationBody `json:"presence,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeDataPackDeclarationBody struct {
|
||||
Key string `json:"key"`
|
||||
DatabaseUserVersion int `json:"databaseUserVersion"`
|
||||
LogParserRefs []string `json:"logParserRefs"`
|
||||
ConfigMapRefs []string `json:"configMapRefs"`
|
||||
DataRefs []string `json:"dataRefs,omitempty"`
|
||||
}
|
||||
@@ -383,34 +353,16 @@ type GameClientBridgeFeatureDeclarationBody struct {
|
||||
RequiredEventProducers []string `json:"requiredEventProducers,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeCompanionDeclarationBody struct {
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ConfigTemplateKey string `json:"configTemplateKey"`
|
||||
ConfigSchemaRef string `json:"configSchemaRef"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
PlatformBaseURLSource string `json:"platformBaseUrlSource"`
|
||||
RegistrationProof string `json:"registrationProof"`
|
||||
ProofMaterialSource string `json:"proofMaterialSource"`
|
||||
ProofMaterialEnv string `json:"proofMaterialEnv"`
|
||||
SessionMode string `json:"sessionMode"`
|
||||
TLSPolicy string `json:"tlsPolicy"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds"`
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type GameClientBridgeManifestBody struct {
|
||||
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
|
||||
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
|
||||
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
|
||||
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
|
||||
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
|
||||
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
|
||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||
MaxCommands int `json:"maxCommands"`
|
||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||
}
|
||||
type GamePluginManifestBody struct {
|
||||
ID string `json:"id"`
|
||||
@@ -936,31 +888,31 @@ type JobRetryPolicyResponse struct {
|
||||
}
|
||||
|
||||
type JobResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State domain.JobState `json:"state"`
|
||||
Progress JobProgressBody `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
ExecutionResult JobExecutionResultResponse `json:"executionResult,omitempty"`
|
||||
RetryPolicy JobRetryPolicyResponse `json:"retryPolicy"`
|
||||
Attempt int `json:"attempt"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
AckDeadlineAt *time.Time `json:"ackDeadlineAt,omitempty"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
CancelRequestedAt *time.Time `json:"cancelRequestedAt,omitempty"`
|
||||
CancelCompletedAt *time.Time `json:"cancelCompletedAt,omitempty"`
|
||||
TerminalAt *time.Time `json:"terminalAt,omitempty"`
|
||||
LastReconciledAt *time.Time `json:"lastReconciledAt,omitempty"`
|
||||
ReconcileCount int `json:"reconcileCount"`
|
||||
ReconcileOutcome string `json:"reconcileOutcome,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State domain.JobState `json:"state"`
|
||||
Progress JobProgressBody `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
ExecutionResult *JobExecutionResultResponse `json:"executionResult,omitempty"`
|
||||
RetryPolicy JobRetryPolicyResponse `json:"retryPolicy"`
|
||||
Attempt int `json:"attempt"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
AckDeadlineAt *time.Time `json:"ackDeadlineAt,omitempty"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
CancelRequestedAt *time.Time `json:"cancelRequestedAt,omitempty"`
|
||||
CancelCompletedAt *time.Time `json:"cancelCompletedAt,omitempty"`
|
||||
TerminalAt *time.Time `json:"terminalAt,omitempty"`
|
||||
LastReconciledAt *time.Time `json:"lastReconciledAt,omitempty"`
|
||||
ReconcileCount int `json:"reconcileCount"`
|
||||
ReconcileOutcome string `json:"reconcileOutcome,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type JobExecutionResultResponse struct {
|
||||
@@ -1194,7 +1146,7 @@ func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorksp
|
||||
workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range body.Files {
|
||||
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
workspace.Files = append(workspace.Files, domain.PluginLogicalFile{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range body.ConfigFields {
|
||||
workspace.ConfigFields = append(workspace.ConfigFields, domain.PluginConfigField{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
@@ -1208,7 +1160,7 @@ func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWor
|
||||
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||
}
|
||||
for _, item := range workspace.Files {
|
||||
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, Editable: item.Editable})
|
||||
body.Files = append(body.Files, PluginLogicalFileBody{Key: item.Key, DirectoryKey: item.DirectoryKey, Label: item.Label, Kind: item.Kind, StreamKey: item.StreamKey, TargetKey: item.TargetKey, Editable: item.Editable})
|
||||
}
|
||||
for _, item := range workspace.ConfigFields {
|
||||
body.ConfigFields = append(body.ConfigFields, PluginConfigFieldBody{Key: item.Key, FileKey: item.FileKey, ConfigKey: item.ConfigKey, Label: item.Label, Description: item.Description, Control: item.Control, Minimum: item.Minimum, Maximum: item.Maximum, DefaultValue: item.DefaultValue, RestartImpact: item.RestartImpact})
|
||||
@@ -1253,7 +1205,7 @@ func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestS
|
||||
}
|
||||
|
||||
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation, ConfigWritePolicy: ai.ConfigWritePolicy}
|
||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation}
|
||||
}
|
||||
|
||||
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
||||
@@ -1283,17 +1235,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
for index, template := range body.QueryTemplates {
|
||||
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
|
||||
for index, projection := range body.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections))
|
||||
for index, projection := range body.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection)
|
||||
}
|
||||
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
|
||||
for index, dataPack := range body.DataPacks {
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
||||
for index, page := range body.Pages {
|
||||
@@ -1303,35 +1251,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
for index, feature := range body.Features {
|
||||
features[index] = domain.GameClientBridgeFeatureDeclaration{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: domain.CopyStringSlice(feature.RequiredHandlers), RequiredEventProducers: domain.CopyStringSlice(feature.RequiredEventProducers)}
|
||||
}
|
||||
companion := domain.GameClientBridgeCompanionDeclaration{}
|
||||
if body.Companion != nil {
|
||||
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
|
||||
steps := make([]domain.GameClientBridgeLogProjectionStepDeclaration, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = domain.GameClientBridgeLogProjectionStepDeclaration{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *domain.GameClientBridgeLogProjectionPresenceDeclaration
|
||||
if value.Presence != nil {
|
||||
presence = &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeLogProjectionDeclaration{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetToDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsToDomain(values []GameClientBridgeQueryProjectionDeclarationBody) []domain.GameClientBridgeQueryProjectionDeclaration {
|
||||
@@ -1361,18 +1281,6 @@ func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBu
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetToDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||
return domain.PluginLifecycleActions{
|
||||
Install: actions.Install,
|
||||
@@ -1782,17 +1690,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
for index, template := range value.QueryTemplates {
|
||||
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)}
|
||||
}
|
||||
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
|
||||
for index, projection := range value.LogProjections {
|
||||
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
|
||||
}
|
||||
lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections))
|
||||
for index, projection := range value.LifecycleProjections {
|
||||
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection)
|
||||
}
|
||||
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
|
||||
for index, dataPack := range value.DataPacks {
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
|
||||
}
|
||||
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
||||
for index, page := range value.Pages {
|
||||
@@ -1802,35 +1706,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
for index, feature := range value.Features {
|
||||
features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers}
|
||||
}
|
||||
var companion *GameClientBridgeCompanionDeclarationBody
|
||||
if value.Companion.ProfileKey != "" {
|
||||
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
|
||||
steps := make([]GameClientBridgeLogProjectionStepDeclarationBody, len(value.Steps))
|
||||
for index, step := range value.Steps {
|
||||
steps[index] = GameClientBridgeLogProjectionStepDeclarationBody{Pattern: step.Pattern}
|
||||
}
|
||||
var presence *GameClientBridgeLogProjectionPresenceDeclarationBody
|
||||
if value.Presence != nil {
|
||||
presence = &GameClientBridgeLogProjectionPresenceDeclarationBody{
|
||||
TimestampField: value.Presence.TimestampField,
|
||||
ActiveWindowSeconds: value.Presence.ActiveWindowSeconds,
|
||||
ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget),
|
||||
}
|
||||
}
|
||||
return GameClientBridgeLogProjectionDeclarationBody{
|
||||
Key: value.Key,
|
||||
StreamKeys: domain.CopyStringSlice(value.StreamKeys),
|
||||
Steps: steps,
|
||||
CorrelationFields: domain.CopyStringSlice(value.CorrelationFields),
|
||||
MaxInterveningLines: value.MaxInterveningLines,
|
||||
Target: gameClientBridgeLogProjectionTargetFromDomain(value.Target),
|
||||
Presence: presence,
|
||||
}
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features}
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryProjectionsFromDomain(values []domain.GameClientBridgeQueryProjectionDeclaration) []GameClientBridgeQueryProjectionDeclarationBody {
|
||||
@@ -1860,18 +1736,6 @@ func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClien
|
||||
return &target
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
|
||||
}
|
||||
|
||||
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
target := gameClientBridgeLogProjectionTargetFromDomain(*value)
|
||||
return &target
|
||||
}
|
||||
|
||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
@@ -2126,6 +1990,18 @@ func RunEndpointListFromDomain(endpoints []domain.RunEndpoint) RunEndpointListRe
|
||||
}
|
||||
|
||||
func JobFromDomain(job domain.Job) JobResponse {
|
||||
return jobResponseFromDomain(job, true)
|
||||
}
|
||||
|
||||
func JobListItemFromDomain(job domain.Job) JobResponse {
|
||||
return jobResponseFromDomain(job, false)
|
||||
}
|
||||
|
||||
func jobResponseFromDomain(job domain.Job, includeExecutionResult bool) JobResponse {
|
||||
var executionResult *JobExecutionResultResponse
|
||||
if includeExecutionResult {
|
||||
executionResult = jobExecutionResultFromDomain(job.ExecutionResult)
|
||||
}
|
||||
return JobResponse{
|
||||
ID: job.ID,
|
||||
ServerInstanceID: job.ServerInstanceID,
|
||||
@@ -2137,7 +2013,7 @@ func JobFromDomain(job domain.Job) JobResponse {
|
||||
State: job.State,
|
||||
Progress: progressFromDomain(job.Progress),
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, Summary: job.ExecutionResult.Summary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)},
|
||||
ExecutionResult: executionResult,
|
||||
RetryPolicy: JobRetryPolicyResponse{
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
|
||||
@@ -2159,6 +2035,14 @@ func JobFromDomain(job domain.Job) JobResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func jobExecutionResultFromDomain(result domain.JobExecutionResult) *JobExecutionResultResponse {
|
||||
response := JobExecutionResultResponse{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Summary: result.Summary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(result.ServerDeploymentEvidence)}
|
||||
if response.Kind == "" && response.ProcessState == "" && response.ExitClassification == "" && response.ExitCode == 0 && response.Version == 0 && response.Checksum == "" && response.SizeBytes == 0 && response.Summary == "" && response.ServerDeploymentEvidence == nil {
|
||||
return nil
|
||||
}
|
||||
return &response
|
||||
}
|
||||
|
||||
func optionalTime(value time.Time) *time.Time {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
@@ -2170,7 +2054,7 @@ func optionalTime(value time.Time) *time.Time {
|
||||
func JobListFromDomain(jobs []domain.Job) JobListResponse {
|
||||
items := make([]JobResponse, len(jobs))
|
||||
for i, job := range jobs {
|
||||
items[i] = JobFromDomain(job)
|
||||
items[i] = JobListItemFromDomain(job)
|
||||
}
|
||||
return JobListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
@@ -139,20 +139,15 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3,
|
||||
Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}},
|
||||
}},
|
||||
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
||||
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
||||
Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
||||
}},
|
||||
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||
CommandRetentionSeconds: 86400,
|
||||
MaxCommands: 1000,
|
||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
|
||||
domainManifest := body.ToDomain()
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||
}
|
||||
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
||||
@@ -160,11 +155,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
t.Fatal("query projection target aliases request DTO data")
|
||||
}
|
||||
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId"
|
||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
||||
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
|
||||
t.Fatal("log projection target aliases request DTO data")
|
||||
}
|
||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId"
|
||||
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
||||
if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" {
|
||||
t.Fatal("lifecycle projection target aliases request DTO data")
|
||||
@@ -190,10 +180,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
||||
t.Fatal("query projection target aliases domain data")
|
||||
}
|
||||
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite"
|
||||
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
||||
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
|
||||
t.Fatal("log projection target aliases domain data")
|
||||
}
|
||||
response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated"
|
||||
if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" {
|
||||
t.Fatal("lifecycle projection target aliases domain data")
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestRuntimeLogEventDeclarationRoundTripUsesSafeProjection(t *testing.T) {
|
||||
body := GamePluginRuntimeProfilesBody{
|
||||
LogEvents: []RuntimeLogEventBody{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
|
||||
profiles := body.ToDomain()
|
||||
if len(profiles.LogEvents) != 1 || profiles.LogEvents[0].EventType != "chat.message" || profiles.LogEvents[0].Severity != "info" {
|
||||
t.Fatalf("log event conversion lost declaration fields: %+v", profiles.LogEvents)
|
||||
}
|
||||
profiles.LogEvents[0].Title = "Mutated"
|
||||
if body.LogEvents[0].Title != "Chat message" {
|
||||
t.Fatal("log event domain conversion aliases request DTO data")
|
||||
}
|
||||
|
||||
projection := runtimeProfilesFromDomain(domain.GamePluginRuntimeProfiles{LogEvents: profiles.LogEvents})
|
||||
encoded, err := json.Marshal(projection.LogEvents[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log event projection: %v", err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatalf("decode log event projection: %v", err)
|
||||
}
|
||||
expected := []string{"key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"}
|
||||
if len(fields) != len(expected) {
|
||||
t.Fatalf("log event projection contains unexpected fields: %s", encoded)
|
||||
}
|
||||
for _, field := range expected {
|
||||
if _, exists := fields[field]; !exists {
|
||||
t.Fatalf("log event projection is missing %q: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
@@ -27,7 +29,7 @@ type RuntimeLifecycleProfileBody struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"`
|
||||
TransportKeys []string `json:"transportKeys,omitempty"`
|
||||
ClientManagerRef string `json:"clientManagerRef,omitempty"`
|
||||
ClientManagerRef json.RawMessage `json:"clientManagerRef,omitempty"`
|
||||
DLLExtensionRefs []string `json:"dllExtensionRefs,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
@@ -38,6 +40,7 @@ type RuntimeDependencyProbeBody struct {
|
||||
TargetKey string `json:"targetKey"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||
SteamAppID string `json:"steamAppId,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
@@ -80,21 +83,6 @@ type RuntimeServerVerificationCheckBody struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeServerDeploymentProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
SteamAppID string `json:"steamAppId"`
|
||||
ExecutableKey string `json:"executableKey"`
|
||||
InstallRootKey string `json:"installRootKey"`
|
||||
ConfigKey string `json:"configKey"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
Prerequisites []RuntimeServerPrerequisiteBody `json:"prerequisites,omitempty"`
|
||||
ConfigMappings []RuntimeServerConfigMappingBody `json:"configMappings"`
|
||||
DiscoveryMarkers []RuntimeServerDiscoveryMarkerBody `json:"discoveryMarkers"`
|
||||
VerificationChecks []RuntimeServerVerificationCheckBody `json:"verificationChecks"`
|
||||
}
|
||||
|
||||
type RuntimeServerPrerequisiteBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -109,17 +97,6 @@ type RuntimeLogSourceBody struct {
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeLogEventBody struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
SourceKey string `json:"sourceKey"`
|
||||
EventType string `json:"eventType"`
|
||||
Permission string `json:"permission"`
|
||||
SchemaRef string `json:"schemaRef"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type RuntimeTransportProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -127,6 +104,18 @@ type RuntimeTransportProfileBody struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type RuntimeDataTargetBody struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeRepositoryBody struct {
|
||||
URL string `json:"url"`
|
||||
RevisionPolicy string `json:"revisionPolicy"`
|
||||
@@ -147,124 +136,89 @@ type RuntimeConfigTemplateBody struct {
|
||||
OutputRef string `json:"outputRef"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerDeploymentBody struct {
|
||||
Mode string `json:"mode"`
|
||||
ExecutableRef string `json:"executableRef"`
|
||||
Arguments []string `json:"arguments,omitempty"`
|
||||
AutoStart bool `json:"autoStart,omitempty"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerLifecycleBody struct {
|
||||
Actions []string `json:"actions"`
|
||||
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
|
||||
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerHealthBody struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
DegradedAfterSeconds int `json:"degradedAfterSeconds"`
|
||||
OfflineAfterSeconds int `json:"offlineAfterSeconds"`
|
||||
RequiredCapabilities []string `json:"requiredCapabilities"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerCompatibilityBody struct {
|
||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||
MaximumVersion string `json:"maximumVersion,omitempty"`
|
||||
AllowDowngrade bool `json:"allowDowngrade"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerUpdatePolicyBody struct {
|
||||
Strategy string `json:"strategy"`
|
||||
RequireApproval bool `json:"requireApproval"`
|
||||
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
|
||||
RetainPrevious bool `json:"retainPrevious"`
|
||||
}
|
||||
|
||||
type RuntimeClientManagerProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Repository RuntimeRepositoryBody `json:"repository"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
Build RuntimeBuildBody `json:"build"`
|
||||
ConfigTemplates []RuntimeConfigTemplateBody `json:"configTemplates,omitempty"`
|
||||
OutputArtifacts []string `json:"outputArtifacts"`
|
||||
Deployment RuntimeClientManagerDeploymentBody `json:"deployment,omitempty"`
|
||||
Lifecycle RuntimeClientManagerLifecycleBody `json:"lifecycle,omitempty"`
|
||||
Health RuntimeClientManagerHealthBody `json:"health,omitempty"`
|
||||
Compatibility RuntimeClientManagerCompatibilityBody `json:"compatibility,omitempty"`
|
||||
UpdatePolicy RuntimeClientManagerUpdatePolicyBody `json:"updatePolicy,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeDLLExtensionProfileBody struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind"`
|
||||
Activation string `json:"activation"`
|
||||
Version string `json:"version"`
|
||||
ReleaseState string `json:"releaseState"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
ReleaseHost string `json:"releaseHost,omitempty"`
|
||||
ReleaseFilename string `json:"releaseFilename,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
UpdateOnStart bool `json:"updateOnStart"`
|
||||
RCONPort int `json:"rconPort"`
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind"`
|
||||
Activation string `json:"activation"`
|
||||
Version string `json:"version"`
|
||||
ReleaseState string `json:"releaseState"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
ReleaseHost string `json:"releaseHost,omitempty"`
|
||||
ReleaseFilename string `json:"releaseFilename,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
UpdateOnStart bool `json:"updateOnStart"`
|
||||
RCONPort int `json:"rconPort"`
|
||||
}
|
||||
|
||||
// RuntimeDLLExtensionProfileResponseBody is the browser-safe projection of a
|
||||
// declared DLL extension. The immutable deployment path, RCON port, and full
|
||||
// release URL remain internal to the manifest/start-job contracts.
|
||||
type RuntimeDLLExtensionProfileResponseBody struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind"`
|
||||
Activation string `json:"activation"`
|
||||
Version string `json:"version"`
|
||||
ReleaseState string `json:"releaseState"`
|
||||
ReleaseHost string `json:"releaseHost,omitempty"`
|
||||
ReleaseFilename string `json:"releaseFilename,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
UpdateOnStart bool `json:"updateOnStart"`
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Kind string `json:"kind"`
|
||||
Activation string `json:"activation"`
|
||||
Version string `json:"version"`
|
||||
ReleaseState string `json:"releaseState"`
|
||||
ReleaseHost string `json:"releaseHost,omitempty"`
|
||||
ReleaseFilename string `json:"releaseFilename,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
TargetExecutableChecksum string `json:"targetExecutableChecksum,omitempty"`
|
||||
UE4SSABI string `json:"ue4ssAbi,omitempty"`
|
||||
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
|
||||
UpdateOnStart bool `json:"updateOnStart"`
|
||||
}
|
||||
|
||||
type RuntimeDLLExtensionPlanBody struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
SCUMExecutableChecksum string `json:"scumExecutableChecksum"`
|
||||
UE4SSABI string `json:"ue4ssAbi"`
|
||||
RCONPort int `json:"rconPort"`
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
TargetExecutableChecksum string `json:"targetExecutableChecksum"`
|
||||
UE4SSABI string `json:"ue4ssAbi"`
|
||||
RCONPort int `json:"rconPort"`
|
||||
}
|
||||
|
||||
type GamePluginRuntimeProfilesBody struct {
|
||||
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
|
||||
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
|
||||
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
|
||||
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
|
||||
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
|
||||
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments json.RawMessage `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
ClientManagers json.RawMessage `json:"clientManagers,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
|
||||
func (body GamePluginRuntimeProfilesBody) UnsupportedLegacyProfileViolations(prefix string) []string {
|
||||
var violations []string
|
||||
if len(body.ClientManagers) > 0 {
|
||||
violations = append(violations, prefix+".clientManagers is no longer supported")
|
||||
}
|
||||
for i, profile := range body.LifecycleProfiles {
|
||||
if len(profile.ClientManagerRef) > 0 {
|
||||
violations = append(violations, fmt.Sprintf("%s.lifecycleProfiles[%d].clientManagerRef is no longer supported", prefix, i))
|
||||
}
|
||||
}
|
||||
if len(body.ServerDeployments) > 0 {
|
||||
violations = append(violations, prefix+".serverDeployments is no longer supported")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
// GamePluginRuntimeProfilesResponseBody is intentionally distinct from the
|
||||
@@ -275,11 +229,9 @@ type GamePluginRuntimeProfilesResponseBody struct {
|
||||
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
|
||||
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
|
||||
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
|
||||
ServerDeployments []RuntimeServerDeploymentProfileBody `json:"serverDeployments,omitempty"`
|
||||
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
|
||||
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
|
||||
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
|
||||
DataTargets []RuntimeDataTargetBody `json:"dataTargets,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionProfileResponseBody `json:"dllExtensions,omitempty"`
|
||||
}
|
||||
|
||||
@@ -289,10 +241,10 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
||||
profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.LifecycleProfiles {
|
||||
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.DependencyProbes {
|
||||
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.InstallPlans {
|
||||
plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)}
|
||||
@@ -301,55 +253,17 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
|
||||
}
|
||||
profiles.InstallPlans = append(profiles.InstallPlans, plan)
|
||||
}
|
||||
for _, item := range body.ServerDeployments {
|
||||
profile := domain.RuntimeServerDeploymentProfile{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
|
||||
for _, prerequisite := range item.Prerequisites {
|
||||
profile.Prerequisites = append(profile.Prerequisites, domain.RuntimeServerPrerequisite{Key: prerequisite.Key, Kind: prerequisite.Kind})
|
||||
}
|
||||
for _, target := range item.SupportedTargets {
|
||||
profile.SupportedTargets = append(profile.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, mapping := range item.ConfigMappings {
|
||||
profile.ConfigMappings = append(profile.ConfigMappings, domain.RuntimeServerConfigMapping{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
|
||||
}
|
||||
for _, marker := range item.DiscoveryMarkers {
|
||||
profile.DiscoveryMarkers = append(profile.DiscoveryMarkers, domain.RuntimeServerDiscoveryMarker{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
|
||||
}
|
||||
for _, check := range item.VerificationChecks {
|
||||
profile.VerificationChecks = append(profile.VerificationChecks, domain.RuntimeServerVerificationCheck{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
|
||||
}
|
||||
profiles.ServerDeployments = append(profiles.ServerDeployments, profile)
|
||||
}
|
||||
for _, item := range body.LogSources {
|
||||
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range body.LogEvents {
|
||||
profiles.LogEvents = append(profiles.LogEvents, domain.RuntimeLogEvent{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: domain.RuntimeLogEventSeverity(item.Severity)})
|
||||
}
|
||||
for _, item := range body.TransportProfiles {
|
||||
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
|
||||
}
|
||||
for _, item := range body.ClientManagers {
|
||||
manager := domain.RuntimeClientManagerProfile{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
RepositoryURL: item.Repository.URL, RevisionPolicy: item.Repository.RevisionPolicy, Branch: item.Repository.Branch, Tag: item.Repository.Tag, Revision: item.Repository.Revision,
|
||||
BuildSystem: item.Build.System, WorkspaceRef: item.Build.WorkspaceRef, EntryRef: item.Build.EntryRef, OutputArtifacts: domain.CopyStringSlice(item.OutputArtifacts),
|
||||
Deployment: domain.RuntimeClientManagerDeployment{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: domain.CopyStringSlice(item.Deployment.Arguments), AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: domain.CopyStringSlice(item.Deployment.RequiredRunCapabilities)},
|
||||
Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: domain.CopyStringSlice(item.Lifecycle.Actions), StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
|
||||
Health: domain.RuntimeClientManagerHealth{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: domain.CopyStringSlice(item.Health.RequiredCapabilities)},
|
||||
Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
|
||||
UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
|
||||
}
|
||||
for _, target := range item.SupportedTargets {
|
||||
manager.SupportedTargets = append(manager.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, config := range item.ConfigTemplates {
|
||||
manager.ConfigTemplates = append(manager.ConfigTemplates, domain.RuntimeConfigTemplate{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
|
||||
}
|
||||
profiles.ClientManagers = append(profiles.ClientManagers, manager)
|
||||
for _, item := range body.DataTargets {
|
||||
profiles.DataTargets = append(profiles.DataTargets, domain.RuntimeDataTarget{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: domain.CopyStringSlice(item.Platforms)})
|
||||
}
|
||||
for _, item := range body.DLLExtensions {
|
||||
extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort}
|
||||
extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, TargetExecutableChecksum: item.TargetExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort}
|
||||
for _, target := range item.SupportedTargets {
|
||||
extension.SupportedTargets = append(extension.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
@@ -365,10 +279,10 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
body.Discovery = append(body.Discovery, RuntimeDiscoveryProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.LifecycleProfiles {
|
||||
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
|
||||
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.DependencyProbes {
|
||||
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms})
|
||||
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, SteamAppID: item.SteamAppID, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.InstallPlans {
|
||||
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
|
||||
@@ -377,56 +291,18 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
}
|
||||
body.InstallPlans = append(body.InstallPlans, plan)
|
||||
}
|
||||
for _, item := range profiles.ServerDeployments {
|
||||
bodyProfile := RuntimeServerDeploymentProfileBody{Key: item.Key, Version: item.Version, SteamAppID: item.SteamAppID, ExecutableKey: item.ExecutableKey, InstallRootKey: item.InstallRootKey, ConfigKey: item.ConfigKey, ConfigFormat: item.ConfigFormat}
|
||||
for _, prerequisite := range item.Prerequisites {
|
||||
bodyProfile.Prerequisites = append(bodyProfile.Prerequisites, RuntimeServerPrerequisiteBody{Key: prerequisite.Key, Kind: prerequisite.Kind})
|
||||
}
|
||||
for _, target := range item.SupportedTargets {
|
||||
bodyProfile.SupportedTargets = append(bodyProfile.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, mapping := range item.ConfigMappings {
|
||||
bodyProfile.ConfigMappings = append(bodyProfile.ConfigMappings, RuntimeServerConfigMappingBody{FieldKey: mapping.FieldKey, ConfigKey: mapping.ConfigKey, ValueType: mapping.ValueType, Required: mapping.Required})
|
||||
}
|
||||
for _, marker := range item.DiscoveryMarkers {
|
||||
bodyProfile.DiscoveryMarkers = append(bodyProfile.DiscoveryMarkers, RuntimeServerDiscoveryMarkerBody{Key: marker.Key, Kind: marker.Kind, TargetKey: marker.TargetKey, Expected: marker.Expected, Required: marker.Required})
|
||||
}
|
||||
for _, check := range item.VerificationChecks {
|
||||
bodyProfile.VerificationChecks = append(bodyProfile.VerificationChecks, RuntimeServerVerificationCheckBody{Key: check.Key, Kind: check.Kind, TargetKey: check.TargetKey, Required: check.Required})
|
||||
}
|
||||
body.ServerDeployments = append(body.ServerDeployments, bodyProfile)
|
||||
}
|
||||
for _, item := range profiles.LogSources {
|
||||
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
|
||||
}
|
||||
for _, item := range profiles.LogEvents {
|
||||
body.LogEvents = append(body.LogEvents, RuntimeLogEventBody{Key: item.Key, Title: item.Title, SourceKey: item.SourceKey, EventType: item.EventType, Permission: item.Permission, SchemaRef: item.SchemaRef, RetentionDays: item.RetentionDays, Severity: string(item.Severity)})
|
||||
}
|
||||
for _, item := range profiles.TransportProfiles {
|
||||
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
|
||||
}
|
||||
for _, item := range profiles.ClientManagers {
|
||||
manager := RuntimeClientManagerProfileBody{
|
||||
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
|
||||
Repository: RuntimeRepositoryBody{URL: item.RepositoryURL, RevisionPolicy: item.RevisionPolicy, Branch: item.Branch, Tag: item.Tag, Revision: item.Revision},
|
||||
Build: RuntimeBuildBody{System: item.BuildSystem, WorkspaceRef: item.WorkspaceRef, EntryRef: item.EntryRef}, OutputArtifacts: item.OutputArtifacts,
|
||||
Deployment: RuntimeClientManagerDeploymentBody{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: item.Deployment.Arguments, AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: item.Deployment.RequiredRunCapabilities},
|
||||
Lifecycle: RuntimeClientManagerLifecycleBody{Actions: item.Lifecycle.Actions, StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
|
||||
Health: RuntimeClientManagerHealthBody{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: item.Health.RequiredCapabilities},
|
||||
Compatibility: RuntimeClientManagerCompatibilityBody{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
|
||||
UpdatePolicy: RuntimeClientManagerUpdatePolicyBody{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
|
||||
}
|
||||
for _, target := range item.SupportedTargets {
|
||||
manager.SupportedTargets = append(manager.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, config := range item.ConfigTemplates {
|
||||
manager.ConfigTemplates = append(manager.ConfigTemplates, RuntimeConfigTemplateBody{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
|
||||
}
|
||||
body.ClientManagers = append(body.ClientManagers, manager)
|
||||
for _, item := range profiles.DataTargets {
|
||||
body.DataTargets = append(body.DataTargets, RuntimeDataTargetBody{Key: item.Key, Kind: item.Kind, TransportKey: item.TransportKey, SourceRootKey: item.SourceRootKey, SourcePath: item.SourcePath, WorkspaceKey: item.WorkspaceKey, RefreshPolicy: item.RefreshPolicy, MaxBytes: item.MaxBytes, Platforms: item.Platforms})
|
||||
}
|
||||
for _, item := range profiles.DLLExtensions {
|
||||
host, filename := safeDLLReleaseLocation(item.ReleaseURL)
|
||||
extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, SCUMExecutableChecksum: safeDLLChecksumPrefix(item.SCUMExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart}
|
||||
extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, TargetExecutableChecksum: safeDLLChecksumPrefix(item.TargetExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart}
|
||||
for _, target := range item.SupportedTargets {
|
||||
extension.SupportedTargets = append(extension.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
@@ -438,7 +314,7 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
|
||||
func dllExtensionPlansFromDomain(plans []domain.RuntimeDLLExtensionPlan) []RuntimeDLLExtensionPlanBody {
|
||||
items := make([]RuntimeDLLExtensionPlanBody, 0, len(plans))
|
||||
for _, item := range plans {
|
||||
items = append(items, RuntimeDLLExtensionPlanBody{Key: item.Key, Version: item.Version, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, RCONPort: item.RCONPort})
|
||||
items = append(items, RuntimeDLLExtensionPlanBody{Key: item.Key, Version: item.Version, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, TargetExecutableChecksum: item.TargetExecutableChecksum, UE4SSABI: item.UE4SSABI, RCONPort: item.RCONPort})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGamePluginRuntimeProfilesBodyRejectsLegacyServerDeployments(t *testing.T) {
|
||||
body := GamePluginRuntimeProfilesBody{ServerDeployments: json.RawMessage(`[{"key":"legacy"}]`)}
|
||||
|
||||
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
|
||||
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.serverDeployments is no longer supported") {
|
||||
t.Fatalf("expected legacy serverDeployments violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagers(t *testing.T) {
|
||||
body := GamePluginRuntimeProfilesBody{ClientManagers: json.RawMessage(`[{"key":"legacy"}]`), LifecycleProfiles: []RuntimeLifecycleProfileBody{{Key: "local", ClientManagerRef: json.RawMessage(`{"key":"legacy"}`)}}}
|
||||
|
||||
violations := body.UnsupportedLegacyProfileViolations("manifest.runtimeProfiles")
|
||||
if len(violations) != 2 || !strings.Contains(strings.Join(violations, "; "), "clientManagers is no longer supported") || !strings.Contains(strings.Join(violations, "; "), "clientManagerRef is no longer supported") {
|
||||
t.Fatalf("expected legacy client manager violations, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGamePluginRuntimeProfilesBodyRejectsLegacyClientManagersWhenNull(t *testing.T) {
|
||||
var body GamePluginRuntimeProfilesBody
|
||||
if err := json.Unmarshal([]byte(`{"clientManagers":null}`), &body); err != nil {
|
||||
t.Fatalf("decode runtime profile body: %v", err)
|
||||
}
|
||||
|
||||
violations := body.UnsupportedLegacyProfileViolations("runtimeProfiles")
|
||||
if len(violations) != 1 || !strings.Contains(violations[0], "runtimeProfiles.clientManagers is no longer supported") {
|
||||
t.Fatalf("expected legacy clientManagers null violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
type SCUMPositionBody struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
}
|
||||
|
||||
type SCUMUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
SteamID string `json:"steamId"`
|
||||
GamePlayerID string `json:"gamePlayerId"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
SquadID int64 `json:"squadId,omitempty"`
|
||||
RegisteredAt time.Time `json:"registeredAt"`
|
||||
LastLoginAt time.Time `json:"lastLoginAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
LastActivityAt time.Time `json:"lastActivityAt"`
|
||||
LastLoginAgeSeconds int64 `json:"lastLoginAgeSeconds,omitempty"`
|
||||
LastLoginIP string `json:"lastLoginIp,omitempty"`
|
||||
LoginIP string `json:"loginIp,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Z *float64 `json:"z,omitempty"`
|
||||
Position *SCUMPositionBody `json:"position,omitempty"`
|
||||
BankBalance *int64 `json:"bankBalance,omitempty"`
|
||||
NormalBalance *int64 `json:"normalBalance,omitempty"`
|
||||
GoldBars *int64 `json:"goldBars,omitempty"`
|
||||
GoldBalance *int64 `json:"goldBalance,omitempty"`
|
||||
RiddenVehicleID string `json:"riddenVehicleId,omitempty"`
|
||||
GameVehicleID string `json:"gameVehicleId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SCUMUserListResponse struct {
|
||||
Items []SCUMUserResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMUserTrajectoryResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
SCUMUserID string `json:"scumUserId"`
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
SteamID string `json:"steamId"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
RiddenVehicleID string `json:"riddenVehicleId,omitempty"`
|
||||
GameVehicleID string `json:"gameVehicleId,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
SampledAt time.Time `json:"sampledAt"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SCUMUserTrajectoryListResponse struct {
|
||||
Items []SCUMUserTrajectoryResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMVehicleResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
VehicleID string `json:"vehicleId"`
|
||||
GameVehicleID string `json:"gameVehicleId"`
|
||||
EntityID string `json:"entityId"`
|
||||
VehicleClass string `json:"vehicleClass,omitempty"`
|
||||
ClassName string `json:"className,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Exists bool `json:"exists"`
|
||||
Status string `json:"status"`
|
||||
Locked bool `json:"locked"`
|
||||
Functional *bool `json:"functional,omitempty"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Z *float64 `json:"z,omitempty"`
|
||||
Position *SCUMPositionBody `json:"position,omitempty"`
|
||||
LastObservedAt time.Time `json:"lastObservedAt"`
|
||||
VehicleObservedAt time.Time `json:"vehicleObservedAt"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SCUMVehicleListResponse struct {
|
||||
Items []SCUMVehicleResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMVehicleTrajectoryResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
SCUMVehicleID string `json:"scumVehicleId"`
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
VehicleID string `json:"vehicleId"`
|
||||
GameVehicleID string `json:"gameVehicleId"`
|
||||
VehicleClass string `json:"vehicleClass,omitempty"`
|
||||
ClassName string `json:"className,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
SampledAt time.Time `json:"sampledAt"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SCUMVehicleTrajectoryListResponse struct {
|
||||
Items []SCUMVehicleTrajectoryResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMVehicleLockResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
SCUMVehicleID string `json:"scumVehicleId"`
|
||||
GameVehicleID string `json:"gameVehicleId"`
|
||||
SCUMUserID string `json:"scumUserId"`
|
||||
SteamID string `json:"steamId"`
|
||||
LockedAt time.Time `json:"lockedAt"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SCUMVehicleLockListResponse struct {
|
||||
Items []SCUMVehicleLockResponse `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type SCUMSurfaceResponse struct {
|
||||
Users SCUMUserListResponse `json:"users"`
|
||||
Vehicles SCUMVehicleListResponse `json:"vehicles"`
|
||||
UserTrajectories SCUMUserTrajectoryListResponse `json:"userTrajectories"`
|
||||
VehicleTrajectories SCUMVehicleTrajectoryListResponse `json:"vehicleTrajectories"`
|
||||
VehicleLocks SCUMVehicleLockListResponse `json:"vehicleLocks"`
|
||||
}
|
||||
|
||||
func SCUMSurfaceFromDomain(value domain.SCUMSurface) SCUMSurfaceResponse {
|
||||
return SCUMSurfaceResponse{
|
||||
Users: SCUMUserListFromDomain(value.Users), Vehicles: SCUMVehicleListFromDomain(value.Vehicles),
|
||||
UserTrajectories: SCUMUserTrajectoryListFromDomain(value.UserTrajectories),
|
||||
VehicleTrajectories: SCUMVehicleTrajectoryListFromDomain(value.VehicleTrajectories),
|
||||
VehicleLocks: SCUMVehicleLockListFromDomain(value.VehicleLocks),
|
||||
}
|
||||
}
|
||||
|
||||
type SCUMFactIngestRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Users []SCUMUserFactBody `json:"users,omitempty"`
|
||||
Vehicles []SCUMVehicleFactBody `json:"vehicles,omitempty"`
|
||||
}
|
||||
|
||||
type SCUMUserFactBody struct {
|
||||
SteamID string `json:"steamId"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
SquadID *int64 `json:"squadId,omitempty"`
|
||||
LoginIP string `json:"loginIp,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
Login bool `json:"login,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
||||
LoginObservedAt time.Time `json:"loginObservedAt,omitempty"`
|
||||
Position *SCUMPositionBody `json:"position,omitempty"`
|
||||
BankBalance *int64 `json:"bankBalance,omitempty"`
|
||||
GoldBars *int64 `json:"goldBars,omitempty"`
|
||||
RiddenGameVehicleID string `json:"riddenGameVehicleId,omitempty"`
|
||||
}
|
||||
|
||||
type SCUMVehicleFactBody struct {
|
||||
GameVehicleID string `json:"gameVehicleId"`
|
||||
VehicleClass string `json:"vehicleClass,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Exists *bool `json:"exists,omitempty"`
|
||||
Locked *bool `json:"locked,omitempty"`
|
||||
Functional *bool `json:"functional,omitempty"`
|
||||
LockedBySteamID string `json:"lockedBySteamId,omitempty"`
|
||||
LockedAt time.Time `json:"lockedAt,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
||||
Position *SCUMPositionBody `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type SCUMFactIngestResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
AcceptedUserCount int `json:"acceptedUserCount"`
|
||||
AcceptedVehicleCount int `json:"acceptedVehicleCount"`
|
||||
WelcomeQueuedCount int `json:"welcomeQueuedCount"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
func SCUMUserListFromDomain(values []domain.SCUMUser) SCUMUserListResponse {
|
||||
items := make([]SCUMUserResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = SCUMUserFromDomain(value)
|
||||
}
|
||||
return SCUMUserListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func SCUMUserFromDomain(value domain.SCUMUser) SCUMUserResponse {
|
||||
value = domain.CopySCUMUser(value)
|
||||
return SCUMUserResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SteamID: value.SteamID, GamePlayerID: value.SteamID, DisplayName: value.DisplayName, SquadID: value.SquadID, RegisteredAt: value.RegisteredAt, LastLoginAt: value.LastLoginAt, LastSeenAt: value.LastActivityAt, LastActivityAt: value.LastActivityAt, LastLoginAgeSeconds: int64(value.LastLoginAge.Seconds()), LastLoginIP: value.LastLoginIP, LoginIP: value.LastLoginIP, Online: value.Online, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), BankBalance: value.BankBalance, NormalBalance: value.BankBalance, GoldBars: value.GoldBars, GoldBalance: value.GoldBars, RiddenVehicleID: value.RiddenVehicleID, GameVehicleID: value.GameVehicleID, Source: "platform.scum_user", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
||||
}
|
||||
|
||||
func SCUMUserTrajectoryListFromDomain(values []domain.SCUMUserTrajectory) SCUMUserTrajectoryListResponse {
|
||||
items := make([]SCUMUserTrajectoryResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = SCUMUserTrajectoryFromDomain(value)
|
||||
}
|
||||
return SCUMUserTrajectoryListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func SCUMUserTrajectoryFromDomain(value domain.SCUMUserTrajectory) SCUMUserTrajectoryResponse {
|
||||
return SCUMUserTrajectoryResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMUserID: value.SCUMUserID, SubjectType: "player", SubjectID: value.SteamID, SteamID: value.SteamID, DisplayName: value.DisplayName, X: value.X, Y: value.Y, Z: value.Z, RiddenVehicleID: value.RiddenVehicleID, GameVehicleID: value.GameVehicleID, ObservedAt: value.ObservedAt, SampledAt: value.ObservedAt, Source: "platform.scum_user_trajectory", CreatedAt: value.CreatedAt}
|
||||
}
|
||||
|
||||
func SCUMVehicleListFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse {
|
||||
items := make([]SCUMVehicleResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = SCUMVehicleFromDomain(value)
|
||||
}
|
||||
return SCUMVehicleListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func SCUMVehicleFromDomain(value domain.SCUMVehicle) SCUMVehicleResponse {
|
||||
value = domain.CopySCUMVehicle(value)
|
||||
status := "missing"
|
||||
if value.Exists {
|
||||
status = "exists"
|
||||
}
|
||||
return SCUMVehicleResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, VehicleID: value.ID, GameVehicleID: value.GameVehicleID, EntityID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, DisplayName: value.DisplayName, Label: value.DisplayName, Exists: value.Exists, Status: status, Locked: value.Locked, Functional: value.Functional, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), LastObservedAt: value.LastObservedAt, VehicleObservedAt: value.LastObservedAt, Source: "platform.scum_vehicle", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
||||
}
|
||||
|
||||
func SCUMVehicleTrajectoryListFromDomain(values []domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryListResponse {
|
||||
items := make([]SCUMVehicleTrajectoryResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = SCUMVehicleTrajectoryFromDomain(value)
|
||||
}
|
||||
return SCUMVehicleTrajectoryListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func SCUMVehicleTrajectoryFromDomain(value domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryResponse {
|
||||
return SCUMVehicleTrajectoryResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMVehicleID: value.SCUMVehicleID, SubjectType: "vehicle", SubjectID: value.GameVehicleID, VehicleID: value.SCUMVehicleID, GameVehicleID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, X: value.X, Y: value.Y, Z: value.Z, ObservedAt: value.ObservedAt, SampledAt: value.ObservedAt, Source: "platform.scum_vehicle_trajectory", CreatedAt: value.CreatedAt}
|
||||
}
|
||||
|
||||
func SCUMVehicleLockListFromDomain(values []domain.SCUMVehicleLock) SCUMVehicleLockListResponse {
|
||||
items := make([]SCUMVehicleLockResponse, len(values))
|
||||
for i, value := range values {
|
||||
items[i] = SCUMVehicleLockFromDomain(value)
|
||||
}
|
||||
return SCUMVehicleLockListResponse{Items: items, Count: len(items)}
|
||||
}
|
||||
|
||||
func SCUMVehicleLockFromDomain(value domain.SCUMVehicleLock) SCUMVehicleLockResponse {
|
||||
return SCUMVehicleLockResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, SCUMVehicleID: value.SCUMVehicleID, GameVehicleID: value.GameVehicleID, SCUMUserID: value.SCUMUserID, SteamID: value.SteamID, LockedAt: value.LockedAt, Source: "platform.scum_vehicle_lock", CreatedAt: value.CreatedAt}
|
||||
}
|
||||
|
||||
func (request SCUMFactIngestRequest) ToDomain() domain.SCUMFactIngest {
|
||||
users := make([]domain.SCUMUserFact, len(request.Users))
|
||||
for i, user := range request.Users {
|
||||
users[i] = user.ToDomain()
|
||||
}
|
||||
vehicles := make([]domain.SCUMVehicleFact, len(request.Vehicles))
|
||||
for i, vehicle := range request.Vehicles {
|
||||
vehicles[i] = vehicle.ToDomain()
|
||||
}
|
||||
return domain.SCUMFactIngest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, ServerInstanceID: request.ServerInstanceID, Users: users, Vehicles: vehicles}
|
||||
}
|
||||
|
||||
func (request SCUMUserFactBody) ToDomain() domain.SCUMUserFact {
|
||||
return domain.SCUMUserFact{SteamID: request.SteamID, DisplayName: request.DisplayName, SquadID: request.SquadID, LoginIP: request.LoginIP, Online: request.Online, Login: request.Login, ObservedAt: request.ObservedAt, LoginObservedAt: request.LoginObservedAt, Position: positionToDomain(request.Position), BankBalance: request.BankBalance, GoldBars: request.GoldBars, RiddenGameVehicleID: request.RiddenGameVehicleID}
|
||||
}
|
||||
|
||||
func (request SCUMVehicleFactBody) ToDomain() domain.SCUMVehicleFact {
|
||||
return domain.SCUMVehicleFact{GameVehicleID: request.GameVehicleID, VehicleClass: request.VehicleClass, DisplayName: request.DisplayName, Exists: request.Exists, Locked: request.Locked, Functional: request.Functional, LockedBySteamID: request.LockedBySteamID, LockedAt: request.LockedAt, ObservedAt: request.ObservedAt, Position: positionToDomain(request.Position)}
|
||||
}
|
||||
|
||||
func SCUMFactIngestFromDomain(result domain.SCUMFactIngestResult) SCUMFactIngestResponse {
|
||||
return SCUMFactIngestResponse{Accepted: result.Accepted, AcceptedUserCount: result.AcceptedUserCount, AcceptedVehicleCount: result.AcceptedVehicleCount, WelcomeQueuedCount: result.WelcomeQueuedCount, ServerTime: result.ServerTime}
|
||||
}
|
||||
|
||||
func positionToDomain(value *SCUMPositionBody) *domain.SCUMPosition {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.SCUMPosition{X: value.X, Y: value.Y, Z: value.Z}
|
||||
}
|
||||
|
||||
func positionFromPointers(x, y, z *float64) *SCUMPositionBody {
|
||||
if x == nil || y == nil || z == nil {
|
||||
return nil
|
||||
}
|
||||
return &SCUMPositionBody{X: *x, Y: *y, Z: *z}
|
||||
}
|
||||
+3
-4
@@ -2,7 +2,6 @@ module browser.local/platform
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
)
|
||||
require github.com/go-sql-driver/mysql v1.10.0
|
||||
|
||||
require filippo.io/edwards25519 v1.2.0 // indirect
|
||||
|
||||
@@ -14,7 +14,7 @@ Required model groups:
|
||||
- log streams and ingestion cursors.
|
||||
- operational events.
|
||||
- server-bound plugin lifecycle installations and linked jobs.
|
||||
- reviewable AI config diffs and approval fences.
|
||||
- AI config execution metadata is carried by durable jobs; retired recommendation records are no longer part of the active model or snapshot schema.
|
||||
|
||||
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
||||
# Durable Client Manager persistence
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
// ClientManagerInstallation is the durable desired/observed lifecycle aggregate for one server/profile.
|
||||
type ClientManagerInstallation struct {
|
||||
// ID is the stable installation identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the installation to one authorized server.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// PluginID identifies the installed game plugin declaration.
|
||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||
// ProfileKey identifies the plugin client-manager profile.
|
||||
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||
// RunEndpointID is the fenced machine executor assignment.
|
||||
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
|
||||
// TargetOS is the package operating-system target.
|
||||
TargetOS string `json:"targetOs" db:"target_os"`
|
||||
// TargetArch is the package architecture target.
|
||||
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||
// Status is the durable lifecycle state.
|
||||
Status domain.ClientManagerLifecycleStatus `json:"status" db:"status"`
|
||||
// Phase is a bounded safe execution phase.
|
||||
Phase string `json:"phase" db:"phase"`
|
||||
DesiredVersion string `json:"desiredVersion" db:"desired_version"`
|
||||
ActiveVersion string `json:"activeVersion" db:"active_version"`
|
||||
PreviousVersion string `json:"previousVersion" db:"previous_version"`
|
||||
DesiredRevision string `json:"desiredRevision" db:"desired_revision"`
|
||||
ActiveRevision string `json:"activeRevision" db:"active_revision"`
|
||||
PreviousRevision string `json:"previousRevision" db:"previous_revision"`
|
||||
DesiredArtifactID string `json:"desiredArtifactId" db:"desired_artifact_id"`
|
||||
ActiveArtifactID string `json:"activeArtifactId" db:"active_artifact_id"`
|
||||
PreviousArtifactID string `json:"previousArtifactId" db:"previous_artifact_id"`
|
||||
Checksum string `json:"checksum" db:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
|
||||
CurrentJobID string `json:"currentJobId" db:"current_job_id"`
|
||||
LastSuccessfulJobID string `json:"lastSuccessfulJobId" db:"last_successful_job_id"`
|
||||
LastOperation domain.ClientManagerLifecycleOperation `json:"lastOperation" db:"last_operation"`
|
||||
Health domain.ClientManagerHealthStatus `json:"health" db:"health"`
|
||||
HealthReason string `json:"healthReason" db:"health_reason"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
|
||||
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
|
||||
Retryable bool `json:"retryable" db:"retryable"`
|
||||
RequiresRedeploy bool `json:"requiresRedeploy" db:"requires_redeploy"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
InstalledAt time.Time `json:"installedAt" db:"installed_at"`
|
||||
UninstalledAt time.Time `json:"uninstalledAt" db:"uninstalled_at"`
|
||||
}
|
||||
|
||||
func (ClientManagerInstallation) TableName() string { return "client_manager_installations" }
|
||||
|
||||
// ClientManagerSession stores only the hash and fences for a short-lived component session.
|
||||
type ClientManagerSession struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
InstallationID string `json:"installationId" db:"installation_id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
|
||||
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
|
||||
TokenHash string `json:"tokenHash" db:"token_hash"`
|
||||
Capabilities []string `json:"capabilities" db:"capabilities"`
|
||||
Status domain.ClientManagerSessionStatus `json:"status" db:"status"`
|
||||
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
RevokedAt time.Time `json:"revokedAt" db:"revoked_at"`
|
||||
}
|
||||
|
||||
func (ClientManagerSession) TableName() string { return "client_manager_sessions" }
|
||||
|
||||
// ClientManagerRegistrationNonce is a bounded replay fence for one signed registration request.
|
||||
type ClientManagerRegistrationNonce struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
InstallationID string `json:"installationId" db:"installation_id"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
func (ClientManagerRegistrationNonce) TableName() string { return "client_manager_registration_nonces" }
|
||||
|
||||
func ClientManagerInstallationFromDomain(value domain.ClientManagerInstallation) ClientManagerInstallation {
|
||||
return ClientManagerInstallation(value)
|
||||
}
|
||||
|
||||
func (value ClientManagerInstallation) ToDomain() domain.ClientManagerInstallation {
|
||||
return domain.ClientManagerInstallation(value)
|
||||
}
|
||||
|
||||
func ClientManagerSessionFromDomain(value domain.ClientManagerSession) ClientManagerSession {
|
||||
value = domain.CopyClientManagerSession(value)
|
||||
return ClientManagerSession(value)
|
||||
}
|
||||
|
||||
func (value ClientManagerSession) ToDomain() domain.ClientManagerSession {
|
||||
return domain.CopyClientManagerSession(domain.ClientManagerSession(value))
|
||||
}
|
||||
|
||||
func ClientManagerRegistrationNonceFromDomain(value domain.ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
|
||||
return ClientManagerRegistrationNonce(value)
|
||||
}
|
||||
|
||||
func (value ClientManagerRegistrationNonce) ToDomain() domain.ClientManagerRegistrationNonce {
|
||||
return domain.ClientManagerRegistrationNonce(value)
|
||||
}
|
||||
@@ -60,28 +60,6 @@ type RunDistribution struct {
|
||||
|
||||
func (RunDistribution) TableName() string { return "run_distributions" }
|
||||
|
||||
type ClientManagerDistribution struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||
Version string `json:"version" db:"version"`
|
||||
TargetOS string `json:"targetOs" db:"target_os"`
|
||||
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
|
||||
SourceRevision string `json:"sourceRevision" db:"source_revision"`
|
||||
BuildJobID string `json:"buildJobId" db:"build_job_id"`
|
||||
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||
Checksum string `json:"checksum" db:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||
SecretRef string `json:"secretRef" db:"secret_ref"`
|
||||
Status domain.DistributionStatus `json:"status" db:"status"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (ClientManagerDistribution) TableName() string { return "client_manager_distributions" }
|
||||
|
||||
type DependencyStatus struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
@@ -103,27 +81,6 @@ type DependencyStatus struct {
|
||||
|
||||
func (DependencyStatus) TableName() string { return "dependency_statuses" }
|
||||
|
||||
type ClientManagerBuildJob struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
PluginID string `json:"pluginId" db:"plugin_id"`
|
||||
ProfileKey string `json:"profileKey" db:"profile_key"`
|
||||
Version string `json:"version" db:"version"`
|
||||
TargetOS string `json:"targetOs" db:"target_os"`
|
||||
TargetArch string `json:"targetArch" db:"target_arch"`
|
||||
RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
|
||||
SourceRevision string `json:"sourceRevision" db:"source_revision"`
|
||||
ArtifactID string `json:"artifactId" db:"artifact_id"`
|
||||
Checksum string `json:"checksum" db:"checksum"`
|
||||
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
|
||||
LogsRef string `json:"logsRef,omitempty" db:"logs_ref"`
|
||||
Status domain.DistributionJobStatus `json:"status" db:"status"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (ClientManagerBuildJob) TableName() string { return "client_manager_build_jobs" }
|
||||
|
||||
type RunUpdateJob struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
@@ -195,14 +152,6 @@ func (distribution RunDistribution) ToDomain() domain.RunDistribution {
|
||||
return domain.RunDistribution(distribution)
|
||||
}
|
||||
|
||||
func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistribution) ClientManagerDistribution {
|
||||
return ClientManagerDistribution(distribution)
|
||||
}
|
||||
|
||||
func (distribution ClientManagerDistribution) ToDomain() domain.ClientManagerDistribution {
|
||||
return domain.ClientManagerDistribution(distribution)
|
||||
}
|
||||
|
||||
func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus {
|
||||
return DependencyStatus(status)
|
||||
}
|
||||
@@ -211,14 +160,6 @@ func (status DependencyStatus) ToDomain() domain.DependencyStatus {
|
||||
return domain.DependencyStatus(status)
|
||||
}
|
||||
|
||||
func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJob {
|
||||
return ClientManagerBuildJob(job)
|
||||
}
|
||||
|
||||
func (job ClientManagerBuildJob) ToDomain() domain.ClientManagerBuildJob {
|
||||
return domain.ClientManagerBuildJob(job)
|
||||
}
|
||||
|
||||
func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJob {
|
||||
return RunUpdateJob(job)
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@ type JobRetryPolicy struct {
|
||||
type JobExecutionInput struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"`
|
||||
Content string `json:"content,omitempty" db:"content"`
|
||||
FileTargetKey string `json:"fileTargetKey,omitempty" db:"file_target_key"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty" db:"expected_version"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty" db:"expected_checksum"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty" db:"max_read_bytes"`
|
||||
@@ -454,33 +455,6 @@ type PluginLifecycleInstallation struct {
|
||||
|
||||
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
||||
|
||||
type AIConfigDiff struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
RequestID string `json:"requestId" db:"request_id"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
||||
ProviderID string `json:"providerId,omitempty" db:"provider_id"`
|
||||
Model string `json:"model,omitempty" db:"model"`
|
||||
Key string `json:"key" db:"key"`
|
||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||
CurrentConfigChecksum string `json:"currentConfigChecksum" db:"current_config_checksum"`
|
||||
ProposedConfig string `json:"proposedConfig" db:"proposed_config"`
|
||||
DiffSummary string `json:"diffSummary" db:"diff_summary"`
|
||||
State domain.AIConfigDiffState `json:"state" db:"state"`
|
||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
||||
ApprovedBy string `json:"approvedBy,omitempty" db:"approved_by"`
|
||||
ApprovedAt time.Time `json:"approvedAt,omitempty" db:"approved_at"`
|
||||
ApprovalIdempotencyKey string `json:"approvalIdempotencyKey,omitempty" db:"approval_idempotency_key"`
|
||||
CancelledBy string `json:"cancelledBy,omitempty" db:"cancelled_by"`
|
||||
CancelledAt time.Time `json:"cancelledAt,omitempty" db:"cancelled_at"`
|
||||
JobID string `json:"jobId,omitempty" db:"job_id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (AIConfigDiff) TableName() string { return "ai_config_diffs" }
|
||||
|
||||
func UserFromDomain(user domain.User) User {
|
||||
user = domain.CopyUser(user)
|
||||
return User{
|
||||
@@ -868,7 +842,7 @@ func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput
|
||||
copy := domain.CopyServerDeploymentDefinition(*input.Deployment)
|
||||
deployment = ©
|
||||
}
|
||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
|
||||
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, FileTargetKey: input.FileTargetKey, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
|
||||
}
|
||||
|
||||
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||
@@ -877,7 +851,7 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
|
||||
copy := domain.CopyServerDeploymentDefinition(*input.Deployment)
|
||||
deployment = ©
|
||||
}
|
||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
|
||||
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, FileTargetKey: input.FileTargetKey, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON), Deployment: deployment, ServerDeploymentPlan: domain.CopyServerDeploymentPlan(input.ServerDeploymentPlan)}
|
||||
}
|
||||
|
||||
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
|
||||
|
||||
@@ -9,19 +9,20 @@ import (
|
||||
|
||||
func TestTableNames(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
User{}.TableName(): "users",
|
||||
AIProvider{}.TableName(): "ai_providers",
|
||||
GamePlugin{}.TableName(): "game_plugins",
|
||||
ServerInstance{}.TableName(): "server_instances",
|
||||
RunEndpoint{}.TableName(): "run_endpoints",
|
||||
Job{}.TableName(): "jobs",
|
||||
Artifact{}.TableName(): "artifacts",
|
||||
LogStream{}.TableName(): "log_streams",
|
||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
||||
ClientManagerInstallation{}.TableName(): "client_manager_installations",
|
||||
ClientManagerSession{}.TableName(): "client_manager_sessions",
|
||||
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
|
||||
User{}.TableName(): "users",
|
||||
AIProvider{}.TableName(): "ai_providers",
|
||||
GamePlugin{}.TableName(): "game_plugins",
|
||||
ServerInstance{}.TableName(): "server_instances",
|
||||
RunEndpoint{}.TableName(): "run_endpoints",
|
||||
Job{}.TableName(): "jobs",
|
||||
Artifact{}.TableName(): "artifacts",
|
||||
LogStream{}.TableName(): "log_streams",
|
||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||
SCUMUser{}.TableName(): "scum_user",
|
||||
SCUMUserTrajectory{}.TableName(): "scum_user_trajectory",
|
||||
SCUMVehicle{}.TableName(): "scum_vehicle",
|
||||
SCUMVehicleTrajectory{}.TableName(): "scum_vehicle_trajectory",
|
||||
SCUMVehicleLock{}.TableName(): "scum_vehicle_lock",
|
||||
}
|
||||
|
||||
for got, want := range tests {
|
||||
@@ -93,6 +94,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
|
||||
func TestJobExecutionInputModelRoundTripPreservesLifecycleMetadata(t *testing.T) {
|
||||
source := domain.JobExecutionInput{
|
||||
WorkspaceScope: "server-workspace",
|
||||
FileTargetKey: "SCUM/Saved/Config/WindowsServer/ServerSettings.ini",
|
||||
PluginID: "game.scum",
|
||||
LifecycleOperation: "upgrade",
|
||||
TargetVersion: "2.0.0",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type SCUMUser struct {
|
||||
// ID is the stable platform SCUM user row identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the record to one platform server instance.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// SteamID stores the user's Steam identifier and is indexed with the server.
|
||||
SteamID string `json:"steamId" db:"steam_id"`
|
||||
// DisplayName is the latest observed in-game display name.
|
||||
DisplayName string `json:"displayName,omitempty" db:"display_name"`
|
||||
// SquadID stores the latest observed SCUM squad identifier.
|
||||
SquadID int64 `json:"squadId,omitempty" db:"squad_id"`
|
||||
// RegisteredAt is when the platform first created this SCUM user record.
|
||||
RegisteredAt time.Time `json:"registeredAt" db:"registered_at"`
|
||||
// LastLoginAt is the latest login timestamp derived from Run-pushed facts.
|
||||
LastLoginAt time.Time `json:"lastLoginAt" db:"last_login_at"`
|
||||
// LastActivityAt is the latest user activity or observed online timestamp.
|
||||
LastActivityAt time.Time `json:"lastActivityAt" db:"last_activity_at"`
|
||||
// LastLoginIP stores the latest login IP reported by plugin-owned facts.
|
||||
LastLoginIP string `json:"lastLoginIp,omitempty" db:"last_login_ip"`
|
||||
// Online marks whether the user is currently considered online by platform facts.
|
||||
Online bool `json:"online" db:"online"`
|
||||
// X stores the latest observed world X coordinate.
|
||||
X *float64 `json:"x,omitempty" db:"x"`
|
||||
// Y stores the latest observed world Y coordinate.
|
||||
Y *float64 `json:"y,omitempty" db:"y"`
|
||||
// Z stores the latest observed world Z coordinate.
|
||||
Z *float64 `json:"z,omitempty" db:"z"`
|
||||
// BankBalance stores the latest observed bank scum-bucks balance.
|
||||
BankBalance *int64 `json:"bankBalance,omitempty" db:"bank_balance"`
|
||||
// GoldBars stores the latest observed gold-bar count.
|
||||
GoldBars *int64 `json:"goldBars,omitempty" db:"gold_bars"`
|
||||
// RiddenVehicleID links to the platform SCUM vehicle row when mounted.
|
||||
RiddenVehicleID string `json:"riddenVehicleId,omitempty" db:"ridden_vehicle_id"`
|
||||
// GameVehicleID stores the in-game vehicle identifier when mounted.
|
||||
GameVehicleID string `json:"gameVehicleId,omitempty" db:"game_vehicle_id"`
|
||||
// WelcomeQueuedAt records the delayed public welcome job eligibility time.
|
||||
WelcomeQueuedAt time.Time `json:"welcomeQueuedAt,omitempty" db:"welcome_queued_at"`
|
||||
// WelcomeLastError stores the latest welcome-message queue failure.
|
||||
WelcomeLastError string `json:"welcomeLastError,omitempty" db:"welcome_last_error"`
|
||||
// CreatedAt is the platform row creation timestamp.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
// UpdatedAt is the latest platform row update timestamp.
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (SCUMUser) TableName() string { return "scum_user" }
|
||||
|
||||
type SCUMUserTrajectory struct {
|
||||
// ID is the stable platform trajectory sample identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the sample to one platform server instance.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// SCUMUserID links to the platform SCUM user row.
|
||||
SCUMUserID string `json:"scumUserId" db:"scum_user_id"`
|
||||
// SteamID stores the user's Steam identifier for indexed lookups.
|
||||
SteamID string `json:"steamId" db:"steam_id"`
|
||||
// DisplayName stores the name observed with this sample.
|
||||
DisplayName string `json:"displayName,omitempty" db:"display_name"`
|
||||
// X stores the sampled world X coordinate.
|
||||
X float64 `json:"x" db:"x"`
|
||||
// Y stores the sampled world Y coordinate.
|
||||
Y float64 `json:"y" db:"y"`
|
||||
// Z stores the sampled world Z coordinate.
|
||||
Z float64 `json:"z" db:"z"`
|
||||
// RiddenVehicleID links to the platform vehicle row when mounted.
|
||||
RiddenVehicleID string `json:"riddenVehicleId,omitempty" db:"ridden_vehicle_id"`
|
||||
// GameVehicleID stores the in-game vehicle identifier when mounted.
|
||||
GameVehicleID string `json:"gameVehicleId,omitempty" db:"game_vehicle_id"`
|
||||
// ObservedAt is when the position fact was observed.
|
||||
ObservedAt time.Time `json:"observedAt" db:"observed_at"`
|
||||
// CreatedAt is the platform row creation timestamp.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
func (SCUMUserTrajectory) TableName() string { return "scum_user_trajectory" }
|
||||
|
||||
type SCUMVehicle struct {
|
||||
// ID is the stable platform SCUM vehicle row identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the record to one platform server instance.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// GameVehicleID stores the in-game vehicle identifier.
|
||||
GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"`
|
||||
// VehicleClass stores the latest observed SCUM vehicle class.
|
||||
VehicleClass string `json:"vehicleClass,omitempty" db:"vehicle_class"`
|
||||
// DisplayName is the user-visible vehicle label.
|
||||
DisplayName string `json:"displayName,omitempty" db:"display_name"`
|
||||
// Exists marks whether the vehicle still exists in game.
|
||||
Exists bool `json:"exists" db:"exists"`
|
||||
// Locked marks the latest observed lock state.
|
||||
Locked bool `json:"locked" db:"locked"`
|
||||
// Functional is the latest game-reported vehicle functional state when available.
|
||||
Functional *bool `json:"functional,omitempty" db:"functional"`
|
||||
// X stores the latest observed world X coordinate.
|
||||
X *float64 `json:"x,omitempty" db:"x"`
|
||||
// Y stores the latest observed world Y coordinate.
|
||||
Y *float64 `json:"y,omitempty" db:"y"`
|
||||
// Z stores the latest observed world Z coordinate.
|
||||
Z *float64 `json:"z,omitempty" db:"z"`
|
||||
// LastObservedAt is when the latest vehicle fact was observed.
|
||||
LastObservedAt time.Time `json:"lastObservedAt" db:"last_observed_at"`
|
||||
// CreatedAt is the platform row creation timestamp.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
// UpdatedAt is the latest platform row update timestamp.
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
func (SCUMVehicle) TableName() string { return "scum_vehicle" }
|
||||
|
||||
type SCUMVehicleTrajectory struct {
|
||||
// ID is the stable platform vehicle trajectory sample identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the sample to one platform server instance.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// SCUMVehicleID links to the platform SCUM vehicle row.
|
||||
SCUMVehicleID string `json:"scumVehicleId" db:"scum_vehicle_id"`
|
||||
// GameVehicleID stores the in-game vehicle identifier.
|
||||
GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"`
|
||||
// VehicleClass stores the vehicle class observed with this sample.
|
||||
VehicleClass string `json:"vehicleClass,omitempty" db:"vehicle_class"`
|
||||
// X stores the sampled world X coordinate.
|
||||
X float64 `json:"x" db:"x"`
|
||||
// Y stores the sampled world Y coordinate.
|
||||
Y float64 `json:"y" db:"y"`
|
||||
// Z stores the sampled world Z coordinate.
|
||||
Z float64 `json:"z" db:"z"`
|
||||
// ObservedAt is when the position fact was observed.
|
||||
ObservedAt time.Time `json:"observedAt" db:"observed_at"`
|
||||
// CreatedAt is the platform row creation timestamp.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
func (SCUMVehicleTrajectory) TableName() string { return "scum_vehicle_trajectory" }
|
||||
|
||||
type SCUMVehicleLock struct {
|
||||
// ID is the stable platform vehicle-lock history row identifier.
|
||||
ID string `json:"id" db:"id"`
|
||||
// ServerInstanceID scopes the lock event to one platform server instance.
|
||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
||||
// SCUMVehicleID links to the platform SCUM vehicle row.
|
||||
SCUMVehicleID string `json:"scumVehicleId" db:"scum_vehicle_id"`
|
||||
// GameVehicleID stores the in-game vehicle identifier.
|
||||
GameVehicleID string `json:"gameVehicleId" db:"game_vehicle_id"`
|
||||
// SCUMUserID links to the platform SCUM user row that applied the lock.
|
||||
SCUMUserID string `json:"scumUserId" db:"scum_user_id"`
|
||||
// SteamID stores the locking user's Steam identifier.
|
||||
SteamID string `json:"steamId" db:"steam_id"`
|
||||
// LockedAt is when the vehicle lock was observed or applied.
|
||||
LockedAt time.Time `json:"lockedAt" db:"locked_at"`
|
||||
// CreatedAt is the platform row creation timestamp.
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
|
||||
func (SCUMVehicleLock) TableName() string { return "scum_vehicle_lock" }
|
||||
@@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden
|
||||
|
||||
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
||||
|
||||
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
||||
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. A config suggestion response includes the proposed content plus `configExecution` metadata for the bounded `config.write` job that Platform dispatches immediately after the current config version/checksum, permissions, runtime binding, and content-safety checks pass. The response contains the job ID and target key so the caller can follow execution logs without receiving provider credentials.
|
||||
|
||||
@@ -6,8 +6,8 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
|
||||
|
||||
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
|
||||
2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale plan evidence is denied and reported.
|
||||
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal.
|
||||
4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
|
||||
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, file, package-manager, and verified HTTPS download adapters with bounded output/timeouts and a durable step journal.
|
||||
4. Terminal evidence is typed dependency status, not log parsing. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
|
||||
|
||||
## Self-update flow
|
||||
|
||||
@@ -16,4 +16,4 @@ Platform owns the reviewable dependency catalog, immutable plan digest, selected
|
||||
3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure.
|
||||
4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation.
|
||||
|
||||
Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, external mirrors/storage, or real AI-provider integration.
|
||||
Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, plugin lifecycle, external mirrors/storage, or real AI-provider integration.
|
||||
|
||||
@@ -89,11 +89,11 @@ Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonom
|
||||
|
||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
|
||||
The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and operational records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes.
|
||||
The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH`, keeps high-frequency Run endpoint/session runtime state in a lightweight sibling runtime snapshot, and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and operational records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes.
|
||||
|
||||
## Artifact
|
||||
|
||||
Implemented HTTP JSON routes:
|
||||
Implemented HTTP routes:
|
||||
|
||||
- `POST /api/v1/run/artifacts/open`
|
||||
- `POST /api/v1/run/artifacts/chunks`
|
||||
@@ -112,7 +112,7 @@ Named artifact DTOs:
|
||||
- `ArtifactTransferCompleteResponse`
|
||||
- `ArtifactResponse`
|
||||
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded JSON chunk payloads, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
|
||||
Artifact upload supports active run session validation, job/server-instance owner scoping, bounded octet-stream chunk bodies, per-chunk checksum validation, duplicate chunk acknowledgement, resume status, and final checksum verification before an artifact becomes available. Artifact transport is separate from control, job result, log ingest, plugin bridge, and browser file APIs.
|
||||
|
||||
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
|
||||
|
||||
@@ -130,14 +130,6 @@ Browser uploads are first staged as server-instance artifacts. Platform then que
|
||||
|
||||
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
|
||||
|
||||
## Client Manager lifecycle channel
|
||||
|
||||
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
|
||||
|
||||
Run materializes the declared output such as `config.yaml` from the fenced values and its own configured Platform control URL. Source template values are not credentials and must not override the generated component identity or policy. The lifecycle input never contains the component proof itself, a component session, a browser credential, a host path, or a direct socket; proof remains inside the component package and is supplied to the supervised process only through the declared environment-variable name.
|
||||
|
||||
Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement.
|
||||
|
||||
## Game Client Bridge
|
||||
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport.
|
||||
The optional game client bridge is separate from run lifecycle, control registration, job handling, log ingest, and artifact transport. Platform exposes operator-scoped command queues, snapshots, query templates, and plugin-owned typed records; it does not expose a component-session companion channel.
|
||||
|
||||
@@ -26,11 +26,9 @@ from command text. Empty `shell` means argv-oriented execution.
|
||||
|
||||
Game plugins own concrete game policy: install/update commands, app ids,
|
||||
executable refs, default launch flags, stop-before-update behavior, and startup
|
||||
argument construction. For SCUM, the plugin action assets own the SteamCMD flow:
|
||||
stop `SCUMServer.exe` when updating, keep SteamCMD outside the server install
|
||||
root, run
|
||||
`steamcmd.exe +force_install_dir <serverRoot> +login anonymous +app_update 3792580 +quit`,
|
||||
and start `<serverRoot>\\SCUM\\Binaries\\Win64\\SCUMServer.exe -port=<gamePort> -MaxPlayers=<maxPlayers> -log`.
|
||||
argument construction. For SCUM, those values live in the SCUM plugin action
|
||||
assets and scripts; Platform and Run only pass the bounded deployment context to
|
||||
the declared lifecycle action and execute it through generic action handling.
|
||||
|
||||
Generated Run distributions carry validated plugin lifecycle assets into the
|
||||
server-scoped workspace. Run materializes those assets at startup and executes
|
||||
|
||||
@@ -38,9 +38,9 @@ A server instance is created from one installed game management plugin and is la
|
||||
|
||||
- `create`: validate plugin and create the instance record without requiring a run endpoint, deployment target, or runtime profile.
|
||||
- `start`: record/authorize operator intent and route bounded control to the bound Run when applicable; generated Run startup is driven by its package-local autonomous lifecycle plan.
|
||||
- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable.
|
||||
- `restart`: dispatch stop/start or plugin-defined restart job.
|
||||
- `update`: dispatch server update job and record version/result.
|
||||
- `stop`: record/authorize operator intent and route bounded control to the bound Run when applicable. Run runs the plugin-declared graceful stop step first and escalates to bounded termination only when that action declares `fallback: "terminate"`.
|
||||
- `restart`: queues the plugin-declared stop action for a `running` or `stopped` instance and queues the plugin-declared start action after the terminal stop job succeeds. A failed or cancelled stop leaves the instance failed and never starts the server.
|
||||
- `update`: queues the plugin-declared install/update action for a `running`, `stopped`, `ready`, or `failed` instance and queues the plugin-declared start action after the update job succeeds. The plugin owns the graceful close inside its install action, so an update never replaces server files under a live process.
|
||||
- `delete`: stop server when needed, preserve or remove artifacts according to policy, mark deleted.
|
||||
|
||||
## Implemented Workflow Routes
|
||||
@@ -51,7 +51,9 @@ A server instance is created from one installed game management plugin and is la
|
||||
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs.
|
||||
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
|
||||
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
|
||||
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
|
||||
- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation.
|
||||
- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation.
|
||||
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses the platform-mediated direct dispatch response from `config.suggest`; the response carries typed `configExecution` job metadata without returning provider credentials to plugin pages.
|
||||
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
|
||||
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
|
||||
|
||||
@@ -59,7 +61,7 @@ Workflow route responses include the accepted action, bounded server instance me
|
||||
|
||||
Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
|
||||
|
||||
Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
||||
Config write dispatch and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
||||
|
||||
Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes.
|
||||
|
||||
@@ -73,3 +75,24 @@ Platform-visible lifecycle state is a projection from Run-reported facts. Termin
|
||||
- `process.install`, `process.start`, or `process.stop` + `failed` or `cancelled` marks the instance `failed`.
|
||||
|
||||
Active start and stop jobs do not introduce separate `starting` or `stopping` states in this change. Operators can inspect pending job state through the job list while the instance remains in its last terminal server state.
|
||||
|
||||
A terminal succeeded `restart` or `update` job queues the plugin-declared start action for the same instance and config version with idempotency key `<restart|update key>:start`. The follow-up start runs through the same start validation as an explicit operator start. Active start, stop, restart, and update jobs do not introduce separate `starting`, `stopping`, `restarting`, or `updating` states; operators can inspect pending job state through the job list while the instance remains in its last terminal server state.
|
||||
|
||||
## Plugin-Declared Graceful Stop
|
||||
|
||||
Run performs no game-specific shutdown logic. A plugin stop action may declare a `gracefulStop` step in `actions/stop.json`:
|
||||
|
||||
```json
|
||||
"gracefulStop": {
|
||||
"executableKey": "bin/scum-stop.cmd",
|
||||
"environment": { "SERVER_STOP_SHUTDOWN_COMMAND": "Quit" },
|
||||
"timeoutMs": 300000,
|
||||
"fallback": "terminate"
|
||||
}
|
||||
```
|
||||
|
||||
Run runs that declared script first, then reads the supervised process state. If the process has exited, the stop result reports a graceful stop. If it is still running, Run applies the declared fallback: `report` fails the job so the operator can intervene, and `terminate` escalates to the existing bounded termination path. The shutdown command, player notice, wait budget, and Steam app id stay in plugin-owned files and manifest declarations; Run and Platform only implement the generic mechanism.
|
||||
|
||||
## Plugin-Declared Build Comparison
|
||||
|
||||
A plugin may declare a `steam.update` dependency probe with `targetKey` (the SteamCMD executable binding), `steamAppId`, and an optional platform list. Run queries the published build for the app's public branch with SteamCMD and compares it with the installed build recorded in `steamapps/appmanifest_<appId>.acf` under the instance's declared server install root, falling back to the SteamCMD directory. The probe reports bounded evidence of the form `installed=<build> latest=<build> update=yes|no` (or `update=unknown`) so the management console can light up an update action without embedding game policy in the platform.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleRepositoriesPersistAndFilter(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create file store: %v", err)
|
||||
}
|
||||
stamp := time.Date(2026, 7, 18, 3, 0, 0, 0, time.UTC)
|
||||
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", ActiveVersion: "1.0.0", ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3, Health: domain.ClientManagerHealthHealthy, LastSeenAt: stamp, CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatalf("persist installation: %v", err)
|
||||
}
|
||||
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: 2, DeploymentGeneration: 3, TokenHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Capabilities: []string{"component.register", "component.heartbeat"}, Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(15 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := store.ClientManagerSessions().Create(session); err != nil {
|
||||
t.Fatalf("persist session: %v", err)
|
||||
}
|
||||
nonce := domain.ClientManagerRegistrationNonce{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", InstallationID: installation.ID, CreatedAt: stamp, ExpiresAt: stamp.Add(5 * time.Minute)}
|
||||
if err := store.ClientManagerNonces().Create(nonce); err != nil {
|
||||
t.Fatalf("persist nonce: %v", err)
|
||||
}
|
||||
|
||||
restarted, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload file store: %v", err)
|
||||
}
|
||||
installations, err := restarted.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", Status: domain.ClientManagerLifecycleOnline})
|
||||
if err != nil || len(installations) != 1 || installations[0].DeploymentGeneration != 3 {
|
||||
t.Fatalf("unexpected persisted installations: items=%+v err=%v", installations, err)
|
||||
}
|
||||
sessions, err := restarted.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
||||
if err != nil || len(sessions) != 1 {
|
||||
t.Fatalf("unexpected persisted sessions: items=%+v err=%v", sessions, err)
|
||||
}
|
||||
sessions[0].Capabilities[0] = "mutated"
|
||||
stored, _ := restarted.ClientManagerSessions().Get(session.ID)
|
||||
if stored.Capabilities[0] != "component.register" {
|
||||
t.Fatalf("repository returned shared capability storage: %+v", stored)
|
||||
}
|
||||
expired, err := restarted.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp.Add(6 * time.Minute)})
|
||||
if err != nil || len(expired) != 1 {
|
||||
t.Fatalf("unexpected nonce expiry filter: items=%+v err=%v", expired, err)
|
||||
}
|
||||
if err := restarted.ClientManagerNonces().Delete(nonce.ID); err != nil {
|
||||
t.Fatalf("delete expired nonce: %v", err)
|
||||
}
|
||||
if _, err := restarted.ClientManagerNonces().Get(nonce.ID); err != ErrNotFound {
|
||||
t.Fatalf("expected deleted nonce, got %v", err)
|
||||
}
|
||||
}
|
||||
+192
-102
@@ -13,40 +13,47 @@ import (
|
||||
)
|
||||
|
||||
type StoreSnapshot struct {
|
||||
Users []domain.User `json:"users"`
|
||||
AuthSessions []domain.AuthSessionRecord `json:"authSessions"`
|
||||
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
|
||||
AIProviders []domain.AIProvider `json:"aiProviders"`
|
||||
GamePlugins []domain.GamePlugin `json:"gamePlugins"`
|
||||
ServerInstances []domain.ServerInstance `json:"serverInstances"`
|
||||
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
||||
Jobs []domain.Job `json:"jobs"`
|
||||
Artifacts []domain.Artifact `json:"artifacts"`
|
||||
RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"`
|
||||
EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"`
|
||||
RunDistributions []domain.RunDistribution `json:"runDistributions"`
|
||||
ClientManagerDistributions []domain.ClientManagerDistribution `json:"clientManagerDistributions"`
|
||||
ClientManagerInstallations []domain.ClientManagerInstallation `json:"clientManagerInstallations"`
|
||||
ClientManagerSessions []domain.ClientManagerSession `json:"clientManagerSessions"`
|
||||
ClientManagerNonces []domain.ClientManagerRegistrationNonce `json:"clientManagerNonces"`
|
||||
DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"`
|
||||
ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"`
|
||||
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
|
||||
LogStreams []domain.LogStream `json:"logStreams"`
|
||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||
Backups []domain.BackupRecord `json:"backups"`
|
||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
||||
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
|
||||
Users []domain.User `json:"users"`
|
||||
AuthSessions []domain.AuthSessionRecord `json:"authSessions"`
|
||||
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
|
||||
AIProviders []domain.AIProvider `json:"aiProviders"`
|
||||
GamePlugins []domain.GamePlugin `json:"gamePlugins"`
|
||||
ServerInstances []domain.ServerInstance `json:"serverInstances"`
|
||||
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
||||
Jobs []domain.Job `json:"jobs"`
|
||||
Artifacts []domain.Artifact `json:"artifacts"`
|
||||
RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"`
|
||||
EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"`
|
||||
RunDistributions []domain.RunDistribution `json:"runDistributions"`
|
||||
DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"`
|
||||
RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"`
|
||||
LogStreams []domain.LogStream `json:"logStreams"`
|
||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||
Backups []domain.BackupRecord `json:"backups"`
|
||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||
PluginDataRecords []domain.PluginDataRecord `json:"pluginDataRecords"`
|
||||
SCUMUsers []domain.SCUMUser `json:"scumUsers"`
|
||||
SCUMUserTrajectories []domain.SCUMUserTrajectory `json:"scumUserTrajectories"`
|
||||
SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"`
|
||||
SCUMVehicleTrajectories []domain.SCUMVehicleTrajectory `json:"scumVehicleTrajectories"`
|
||||
SCUMVehicleLocks []domain.SCUMVehicleLock `json:"scumVehicleLocks"`
|
||||
}
|
||||
|
||||
type runtimeSnapshot struct {
|
||||
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
|
||||
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
||||
LogStreams []domain.LogStream `json:"logStreams"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
*MemoryStore
|
||||
path string
|
||||
persistMu sync.Mutex
|
||||
path string
|
||||
runtimePath string
|
||||
persistMu sync.Mutex
|
||||
runtimePersistMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileStore(path string) (*FileStore, error) {
|
||||
@@ -57,6 +64,7 @@ func NewFileStore(path string) (*FileStore, error) {
|
||||
store := &FileStore{
|
||||
MemoryStore: NewMemoryStore(),
|
||||
path: path,
|
||||
runtimePath: runtimeMetadataPath(path),
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
return nil, err
|
||||
@@ -77,7 +85,7 @@ func (store *FileStore) AuthSessions() AuthSessionRepository {
|
||||
}
|
||||
|
||||
func (store *FileStore) RunControlSessions() RunControlSessionRepository {
|
||||
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persist}
|
||||
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *FileStore) AIProviders() AIProviderRepository {
|
||||
@@ -93,7 +101,7 @@ func (store *FileStore) ServerInstances() ServerInstanceRepository {
|
||||
}
|
||||
|
||||
func (store *FileStore) RunEndpoints() RunEndpointRepository {
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist}
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *FileStore) Jobs() JobRepository {
|
||||
@@ -119,36 +127,16 @@ func (store *FileStore) RunDistributions() RunDistributionRepository {
|
||||
return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ClientManagerDistributions() ClientManagerDistributionRepository {
|
||||
return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ClientManagerInstallations() ClientManagerInstallationRepository {
|
||||
return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ClientManagerSessions() ClientManagerSessionRepository {
|
||||
return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ClientManagerNonces() ClientManagerNonceRepository {
|
||||
return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) DependencyStatuses() DependencyStatusRepository {
|
||||
return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
|
||||
return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) RunUpdateJobs() RunUpdateJobRepository {
|
||||
return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) LogStreams() LogStreamRepository {
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
||||
@@ -163,10 +151,6 @@ func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
@@ -184,23 +168,37 @@ func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapsh
|
||||
func (store *FileStore) PluginDataRecords() PluginDataRecordRepository {
|
||||
return &persistentRepository[domain.PluginDataRecord, domain.PluginDataFilter]{repository: store.MemoryStore.pluginDataRecords, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMUsers() SCUMUserRepository {
|
||||
return &persistentRepository[domain.SCUMUser, domain.SCUMUserFilter]{repository: store.MemoryStore.scumUsers, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository {
|
||||
return &persistentRepository[domain.SCUMUserTrajectory, domain.SCUMUserTrajectoryFilter]{repository: store.MemoryStore.scumUserTracks, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMVehicles() SCUMVehicleRepository {
|
||||
return &persistentRepository[domain.SCUMVehicle, domain.SCUMVehicleFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository {
|
||||
return &persistentRepository[domain.SCUMVehicleTrajectory, domain.SCUMVehicleTrajectoryFilter]{repository: store.MemoryStore.scumVehicleTracks, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) SCUMVehicleLocks() SCUMVehicleLockRepository {
|
||||
return &persistentRepository[domain.SCUMVehicleLock, domain.SCUMVehicleLockFilter]{repository: store.MemoryStore.scumVehicleLocks, persist: store.persist}
|
||||
}
|
||||
func (store *FileStore) load() error {
|
||||
data, err := os.ReadFile(store.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
return store.loadRuntime()
|
||||
}
|
||||
return fmt.Errorf("read metadata snapshot: %w", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil
|
||||
if len(strings.TrimSpace(string(data))) != 0 {
|
||||
var snapshot StoreSnapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadSnapshot(snapshot)
|
||||
}
|
||||
var snapshot StoreSnapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadSnapshot(snapshot)
|
||||
return nil
|
||||
return store.loadRuntime()
|
||||
}
|
||||
|
||||
func (store *FileStore) persist() error {
|
||||
@@ -208,7 +206,7 @@ func (store *FileStore) persist() error {
|
||||
defer store.persistMu.Unlock()
|
||||
|
||||
snapshot := store.snapshot()
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode metadata snapshot: %w", err)
|
||||
}
|
||||
@@ -225,40 +223,127 @@ func (store *FileStore) persist() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileStore) loadRuntime() error {
|
||||
data, err := os.ReadFile(store.runtimePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read runtime metadata snapshot: %w", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil
|
||||
}
|
||||
var snapshot runtimeSnapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode runtime metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadRuntimeSnapshot(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileStore) persistRuntime() error {
|
||||
store.runtimePersistMu.Lock()
|
||||
defer store.runtimePersistMu.Unlock()
|
||||
|
||||
snapshot := store.runtimeSnapshot()
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode runtime metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(store.runtimePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create runtime metadata directory: %w", err)
|
||||
}
|
||||
if err := store.ensureMetadataFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := store.runtimePath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write runtime metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, store.runtimePath); err != nil {
|
||||
return fmt.Errorf("replace runtime metadata snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileStore) ensureMetadataFile() error {
|
||||
if _, err := os.Stat(store.path); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat metadata snapshot: %w", err)
|
||||
}
|
||||
|
||||
store.persistMu.Lock()
|
||||
defer store.persistMu.Unlock()
|
||||
if _, err := os.Stat(store.path); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create metadata directory: %w", err)
|
||||
}
|
||||
tmpPath := store.path + ".empty.tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte("{}"), 0o600); err != nil {
|
||||
return fmt.Errorf("write empty metadata snapshot: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, store.path); err != nil {
|
||||
return fmt.Errorf("replace empty metadata snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeMetadataPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
if ext == "" {
|
||||
return path + ".runtime"
|
||||
}
|
||||
return strings.TrimSuffix(path, ext) + ".runtime" + ext
|
||||
}
|
||||
|
||||
func (store *FileStore) snapshot() StoreSnapshot {
|
||||
return StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
|
||||
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
|
||||
RunDistributions: snapshotRepository(store.MemoryStore.runDists),
|
||||
ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists),
|
||||
ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls),
|
||||
ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions),
|
||||
ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces),
|
||||
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
|
||||
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
|
||||
return normalizeStoreSnapshot(StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
|
||||
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
|
||||
RunDistributions: snapshotRepository(store.MemoryStore.runDists),
|
||||
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
|
||||
SCUMUsers: snapshotRepository(store.MemoryStore.scumUsers),
|
||||
SCUMUserTrajectories: snapshotRepository(store.MemoryStore.scumUserTracks),
|
||||
SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles),
|
||||
SCUMVehicleTrajectories: snapshotRepository(store.MemoryStore.scumVehicleTracks),
|
||||
SCUMVehicleLocks: snapshotRepository(store.MemoryStore.scumVehicleLocks),
|
||||
})
|
||||
}
|
||||
|
||||
func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
|
||||
return runtimeSnapshot{
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
snapshot = normalizeStoreSnapshot(snapshot)
|
||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||
@@ -271,22 +356,27 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings)
|
||||
loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys)
|
||||
loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions)
|
||||
loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions)
|
||||
loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations)
|
||||
loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions)
|
||||
loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces)
|
||||
loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses)
|
||||
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
||||
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
|
||||
loadRepository(store.MemoryStore.scumUsers, snapshot.SCUMUsers)
|
||||
loadRepository(store.MemoryStore.scumUserTracks, snapshot.SCUMUserTrajectories)
|
||||
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
|
||||
loadRepository(store.MemoryStore.scumVehicleTracks, snapshot.SCUMVehicleTrajectories)
|
||||
loadRepository(store.MemoryStore.scumVehicleLocks, snapshot.SCUMVehicleLocks)
|
||||
}
|
||||
|
||||
func (store *FileStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
}
|
||||
|
||||
type mutableRepository[T any, F any] interface {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func normalizeStoreSnapshot(snapshot StoreSnapshot) StoreSnapshot {
|
||||
plugins, replacements := latestGamePlugins(snapshot.GamePlugins)
|
||||
snapshot.GamePlugins = plugins
|
||||
snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements)
|
||||
snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements)
|
||||
snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements)
|
||||
snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func latestGamePlugins(values []domain.GamePlugin) ([]domain.GamePlugin, map[string]domain.GamePlugin) {
|
||||
byIdentity := map[string]domain.GamePlugin{}
|
||||
for _, plugin := range values {
|
||||
key := domain.GamePluginIdentityKey(plugin)
|
||||
if current, exists := byIdentity[key]; !exists || domain.GamePluginIsNewer(plugin, current) {
|
||||
byIdentity[key] = plugin
|
||||
}
|
||||
}
|
||||
replacements := map[string]domain.GamePlugin{}
|
||||
out := make([]domain.GamePlugin, 0, len(byIdentity))
|
||||
for _, plugin := range byIdentity {
|
||||
out = append(out, domain.CopyGamePlugin(plugin))
|
||||
}
|
||||
sort.SliceStable(out, func(left, right int) bool { return out[left].ID < out[right].ID })
|
||||
for _, plugin := range values {
|
||||
kept, ok := byIdentity[domain.GamePluginIdentityKey(plugin)]
|
||||
if ok && plugin.ID != kept.ID {
|
||||
replacements[plugin.ID] = kept
|
||||
}
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
|
||||
func rewriteSnapshotServerInstances(values []domain.ServerInstance, replacements map[string]domain.GamePlugin) []domain.ServerInstance {
|
||||
out := make([]domain.ServerInstance, len(values))
|
||||
for index, value := range values {
|
||||
if kept, ok := replacements[value.PluginID]; ok {
|
||||
value.PluginID = kept.ID
|
||||
value.PluginVersion = kept.Version
|
||||
}
|
||||
out[index] = domain.CopyServerInstance(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteSnapshotRuntimeBindings(values []domain.RuntimeBinding, replacements map[string]domain.GamePlugin) []domain.RuntimeBinding {
|
||||
out := make([]domain.RuntimeBinding, len(values))
|
||||
for index, value := range values {
|
||||
if kept, ok := replacements[value.PluginID]; ok {
|
||||
value.PluginID = kept.ID
|
||||
value.PluginVersion = kept.Version
|
||||
}
|
||||
out[index] = domain.CopyRuntimeBinding(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteSnapshotPluginLifecycles(values []domain.PluginLifecycleInstallation, replacements map[string]domain.GamePlugin) []domain.PluginLifecycleInstallation {
|
||||
out := make([]domain.PluginLifecycleInstallation, len(values))
|
||||
for index, value := range values {
|
||||
if kept, ok := replacements[value.PluginID]; ok {
|
||||
value.PluginID = kept.ID
|
||||
if value.TargetVersion != "" {
|
||||
value.TargetVersion = kept.Version
|
||||
}
|
||||
}
|
||||
out[index] = domain.CopyPluginLifecycleInstallation(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord {
|
||||
byID := map[string]domain.PluginDataRecord{}
|
||||
for _, value := range values {
|
||||
if isLegacySCUMPluginData(value.PluginID, value.Collection) {
|
||||
continue
|
||||
}
|
||||
if kept, ok := replacements[value.PluginID]; ok {
|
||||
value.PluginID = kept.ID
|
||||
value.ID = snapshotPluginDataID(value.ServerInstanceID, kept.ID, value.Collection, value.Key)
|
||||
}
|
||||
if current, exists := byID[value.ID]; !exists || value.UpdatedAt.After(current.UpdatedAt) || (value.UpdatedAt.Equal(current.UpdatedAt) && value.CreatedAt.After(current.CreatedAt)) {
|
||||
byID[value.ID] = domain.CopyPluginDataRecord(value)
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
out := make([]domain.PluginDataRecord, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, domain.CopyPluginDataRecord(byID[id]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isLegacySCUMPluginData(pluginID string, collection string) bool {
|
||||
if !isSCUMPluginID(pluginID) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(collection)) {
|
||||
case "scum_users", "scum_players", "scum_trajectories", "scum_user_trajectories", "scum_vehicles", "scum_vehicle_trajectories", "scum_vehicle_locks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSCUMPluginID(pluginID string) bool {
|
||||
canonical := domain.CanonicalGamePluginID(pluginID)
|
||||
return canonical == "game.scum" || canonical == "server.scum"
|
||||
}
|
||||
|
||||
func snapshotPluginDataID(serverID, pluginID, collection, key string) string {
|
||||
sum := sha256.Sum256([]byte(serverID + "\x00" + pluginID + "\x00" + collection + "\x00" + key))
|
||||
return "plugin-data-" + hex.EncodeToString(sum[:])[:24]
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// The SCUM tables are platform-owned storage for the typed SCUM facts Run
|
||||
// pushes for one server instance. Columns mirror platform/model/scum.go and
|
||||
// carry the indexes the platform queries below rely on.
|
||||
const (
|
||||
scumMaxListLimit = 1000
|
||||
scumTrajectoryRetention = 30 * 24 * time.Hour
|
||||
scumTrajectoryPruneEvery = time.Hour
|
||||
)
|
||||
|
||||
var scumTableDDL = []struct {
|
||||
table string
|
||||
statement string
|
||||
}{
|
||||
{"scum_user", `CREATE TABLE IF NOT EXISTS scum_user (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
steam_id VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
squad_id BIGINT NOT NULL DEFAULT 0,
|
||||
registered_at DATETIME(6) NULL,
|
||||
last_login_at DATETIME(6) NULL,
|
||||
last_activity_at DATETIME(6) NULL,
|
||||
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
x DOUBLE NULL,
|
||||
y DOUBLE NULL,
|
||||
z DOUBLE NULL,
|
||||
bank_balance BIGINT NULL,
|
||||
gold_bars BIGINT NULL,
|
||||
ridden_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
game_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
welcome_queued_at DATETIME(6) NULL,
|
||||
welcome_last_error VARCHAR(512) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY uq_scum_user_instance_steam (server_instance_id, steam_id),
|
||||
KEY ix_scum_user_presence (server_instance_id, online, last_activity_at),
|
||||
KEY ix_scum_user_updated (server_instance_id, updated_at)
|
||||
)`},
|
||||
{"scum_user_trajectory", `CREATE TABLE IF NOT EXISTS scum_user_trajectory (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
scum_user_id VARCHAR(191) NOT NULL,
|
||||
steam_id VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
x DOUBLE NOT NULL,
|
||||
y DOUBLE NOT NULL,
|
||||
z DOUBLE NOT NULL,
|
||||
ridden_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
game_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
observed_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
KEY ix_scum_user_track_subject (server_instance_id, scum_user_id, observed_at),
|
||||
KEY ix_scum_user_track_steam (server_instance_id, steam_id, observed_at),
|
||||
KEY ix_scum_user_track_retention (observed_at)
|
||||
)`},
|
||||
{"scum_vehicle", `CREATE TABLE IF NOT EXISTS scum_vehicle (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
game_vehicle_id VARCHAR(191) NOT NULL,
|
||||
vehicle_class VARCHAR(64) NOT NULL DEFAULT '',
|
||||
display_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
exists_in_game BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
functional BOOLEAN NULL,
|
||||
x DOUBLE NULL,
|
||||
y DOUBLE NULL,
|
||||
z DOUBLE NULL,
|
||||
last_observed_at DATETIME(6) NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY uq_scum_vehicle_instance_game (server_instance_id, game_vehicle_id),
|
||||
KEY ix_scum_vehicle_presence (server_instance_id, exists_in_game, last_observed_at),
|
||||
KEY ix_scum_vehicle_updated (server_instance_id, updated_at)
|
||||
)`},
|
||||
{"scum_vehicle_trajectory", `CREATE TABLE IF NOT EXISTS scum_vehicle_trajectory (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
scum_vehicle_id VARCHAR(191) NOT NULL,
|
||||
game_vehicle_id VARCHAR(191) NOT NULL,
|
||||
vehicle_class VARCHAR(64) NOT NULL DEFAULT '',
|
||||
x DOUBLE NOT NULL,
|
||||
y DOUBLE NOT NULL,
|
||||
z DOUBLE NOT NULL,
|
||||
observed_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
KEY ix_scum_vehicle_track_vehicle (server_instance_id, scum_vehicle_id, observed_at),
|
||||
KEY ix_scum_vehicle_track_game (server_instance_id, game_vehicle_id, observed_at),
|
||||
KEY ix_scum_vehicle_track_retention (observed_at)
|
||||
)`},
|
||||
{"scum_vehicle_lock", `CREATE TABLE IF NOT EXISTS scum_vehicle_lock (
|
||||
id VARCHAR(191) PRIMARY KEY,
|
||||
server_instance_id VARCHAR(191) NOT NULL,
|
||||
scum_vehicle_id VARCHAR(191) NOT NULL,
|
||||
game_vehicle_id VARCHAR(191) NOT NULL,
|
||||
scum_user_id VARCHAR(191) NOT NULL,
|
||||
steam_id VARCHAR(64) NOT NULL,
|
||||
locked_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
KEY ix_scum_vehicle_lock_vehicle (server_instance_id, scum_vehicle_id, locked_at),
|
||||
KEY ix_scum_vehicle_lock_user (server_instance_id, scum_user_id, locked_at)
|
||||
)`},
|
||||
}
|
||||
|
||||
// initializeSCUMTables creates the typed SCUM tables. An earlier unreleased
|
||||
// build created a throwaway snapshot-shaped table with a payload JSON column
|
||||
// that no code path ever read; that shape is replaced here instead of being
|
||||
// kept around with missing typed columns.
|
||||
func (store *MySQLStore) initializeSCUMTables(ctx context.Context) error {
|
||||
for _, entry := range scumTableDDL {
|
||||
legacy, err := store.scumTableUsesLegacyPayload(ctx, entry.table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if legacy {
|
||||
if _, err := store.db.ExecContext(ctx, "DROP TABLE IF EXISTS "+entry.table); err != nil {
|
||||
return fmt.Errorf("drop legacy SCUM table %s: %w", entry.table, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, entry.statement); err != nil {
|
||||
return fmt.Errorf("create SCUM table %s: %w", entry.table, err)
|
||||
}
|
||||
}
|
||||
if err := store.ensureSCUMVehicleFunctionalColumn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ensureSCUMVehicleFunctionalColumn(ctx context.Context) error {
|
||||
var found int
|
||||
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'scum_vehicle' AND COLUMN_NAME = 'functional'`).Scan(&found); err != nil {
|
||||
return fmt.Errorf("inspect SCUM vehicle functional column: %w", err)
|
||||
}
|
||||
if found > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `ALTER TABLE scum_vehicle ADD COLUMN functional BOOLEAN NULL AFTER locked`); err != nil {
|
||||
return fmt.Errorf("add SCUM vehicle functional column: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) scumTableUsesLegacyPayload(ctx context.Context, table string) (bool, error) {
|
||||
var found int
|
||||
err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'payload'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.COLUMNS c
|
||||
WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? AND c.COLUMN_NAME = 'server_instance_id')`, table, table).Scan(&found)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect SCUM table %s: %w", table, err)
|
||||
}
|
||||
return found > 0, nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) SCUMUsers() SCUMUserRepository {
|
||||
return &mysqlSCUMUserRepository{db: store.db}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository {
|
||||
return &mysqlSCUMUserTrajectoryRepository{db: store.db, pruner: &store.scumUserTrackPruner}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository {
|
||||
return &mysqlSCUMVehicleRepository{db: store.db}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository {
|
||||
return &mysqlSCUMVehicleTrajectoryRepository{db: store.db, pruner: &store.scumVehicleTrackPruner}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) SCUMVehicleLocks() SCUMVehicleLockRepository {
|
||||
return &mysqlSCUMVehicleLockRepository{db: store.db}
|
||||
}
|
||||
|
||||
type mysqlSCUMUserRepository struct{ db *sql.DB }
|
||||
|
||||
const scumUserColumns = `id, server_instance_id, steam_id, display_name, squad_id, registered_at, last_login_at, last_activity_at, last_login_ip, online, x, y, z, bank_balance, gold_bars, ridden_vehicle_id, game_vehicle_id, welcome_queued_at, welcome_last_error, created_at, updated_at`
|
||||
|
||||
func (repository *mysqlSCUMUserRepository) Create(value domain.SCUMUser) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_user (`+scumUserColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumUserArgs(value)...)
|
||||
if err != nil {
|
||||
if isMySQLDuplicateKey(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("create scum_user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserRepository) Get(id string) (domain.SCUMUser, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
value, err := scanSCUMUser(repository.db.QueryRowContext(ctx, `SELECT `+scumUserColumns+` FROM scum_user WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.SCUMUser{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.SCUMUser{}, fmt.Errorf("read scum_user: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserRepository) List(filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) {
|
||||
query := `SELECT ` + scumUserColumns + ` FROM scum_user WHERE server_instance_id = ?`
|
||||
args := []any{filter.ServerInstanceID}
|
||||
if filter.SteamID != "" {
|
||||
query += " AND steam_id = ?"
|
||||
args = append(args, filter.SteamID)
|
||||
}
|
||||
if filter.Online != nil {
|
||||
query += " AND online = ?"
|
||||
args = append(args, *filter.Online)
|
||||
}
|
||||
if !filter.ChangedAfter.IsZero() {
|
||||
query += " AND updated_at > ?"
|
||||
args = append(args, filter.ChangedAfter.UTC())
|
||||
}
|
||||
if !filter.StaleBefore.IsZero() {
|
||||
query += " AND COALESCE(last_activity_at, updated_at) < ?"
|
||||
args = append(args, filter.StaleBefore.UTC())
|
||||
}
|
||||
query += " ORDER BY last_activity_at DESC, steam_id ASC LIMIT ?"
|
||||
args = append(args, scumListLimit(filter.Limit))
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
rows, err := repository.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scum_user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]domain.SCUMUser, 0, 16)
|
||||
for rows.Next() {
|
||||
value, err := scanSCUMUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan scum_user: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list scum_user: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserRepository) Update(value domain.SCUMUser) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `UPDATE scum_user SET server_instance_id=?, steam_id=?, display_name=?, squad_id=?, registered_at=?, last_login_at=?, last_activity_at=?, last_login_ip=?, online=?, x=?, y=?, z=?, bank_balance=?, gold_bars=?, ridden_vehicle_id=?, game_vehicle_id=?, welcome_queued_at=?, welcome_last_error=?, created_at=?, updated_at=? WHERE id=?`, scumUserUpdateArgs(value)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scum_user: %w", err)
|
||||
}
|
||||
return scumUpdateResult(result, func() error {
|
||||
_, err := repository.Get(value.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserRepository) Delete(id string) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_user WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete scum_user: %w", err)
|
||||
}
|
||||
return scumDeleteResult(result)
|
||||
}
|
||||
|
||||
type mysqlSCUMVehicleRepository struct{ db *sql.DB }
|
||||
|
||||
const scumVehicleColumns = `id, server_instance_id, game_vehicle_id, vehicle_class, display_name, exists_in_game, locked, functional, x, y, z, last_observed_at, created_at, updated_at`
|
||||
|
||||
func (repository *mysqlSCUMVehicleRepository) Create(value domain.SCUMVehicle) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle (`+scumVehicleColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumVehicleArgs(value)...)
|
||||
if err != nil {
|
||||
if isMySQLDuplicateKey(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("create scum_vehicle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleRepository) Get(id string) (domain.SCUMVehicle, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
value, err := scanSCUMVehicle(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleColumns+` FROM scum_vehicle WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.SCUMVehicle{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.SCUMVehicle{}, fmt.Errorf("read scum_vehicle: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleRepository) List(filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) {
|
||||
query := `SELECT ` + scumVehicleColumns + ` FROM scum_vehicle WHERE server_instance_id = ?`
|
||||
args := []any{filter.ServerInstanceID}
|
||||
if filter.GameVehicleID != "" {
|
||||
query += " AND game_vehicle_id = ?"
|
||||
args = append(args, filter.GameVehicleID)
|
||||
}
|
||||
if filter.Exists != nil {
|
||||
query += " AND exists_in_game = ?"
|
||||
args = append(args, *filter.Exists)
|
||||
}
|
||||
if !filter.ChangedAfter.IsZero() {
|
||||
query += " AND updated_at > ?"
|
||||
args = append(args, filter.ChangedAfter.UTC())
|
||||
}
|
||||
query += " ORDER BY last_observed_at DESC, game_vehicle_id ASC LIMIT ?"
|
||||
args = append(args, scumListLimit(filter.Limit))
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
rows, err := repository.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]domain.SCUMVehicle, 0, 16)
|
||||
for rows.Next() {
|
||||
value, err := scanSCUMVehicle(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan scum_vehicle: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleRepository) Update(value domain.SCUMVehicle) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle SET server_instance_id=?, game_vehicle_id=?, vehicle_class=?, display_name=?, exists_in_game=?, locked=?, functional=?, x=?, y=?, z=?, last_observed_at=?, created_at=?, updated_at=? WHERE id=?`, scumVehicleUpdateArgs(value)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scum_vehicle: %w", err)
|
||||
}
|
||||
return scumUpdateResult(result, func() error {
|
||||
_, err := repository.Get(value.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleRepository) Delete(id string) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete scum_vehicle: %w", err)
|
||||
}
|
||||
return scumDeleteResult(result)
|
||||
}
|
||||
|
||||
type mysqlSCUMUserTrajectoryRepository struct {
|
||||
db *sql.DB
|
||||
pruner *scumTrajectoryPruner
|
||||
}
|
||||
|
||||
const scumUserTrajectoryColumns = `id, server_instance_id, scum_user_id, steam_id, display_name, x, y, z, ridden_vehicle_id, game_vehicle_id, observed_at, created_at`
|
||||
|
||||
func (repository *mysqlSCUMUserTrajectoryRepository) Create(value domain.SCUMUserTrajectory) error {
|
||||
if err := repository.pruner.prune(repository.db, "scum_user_trajectory", "observed_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_user_trajectory (`+scumUserTrajectoryColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMUserID, value.SteamID, value.DisplayName, value.X, value.Y, value.Z, value.RiddenVehicleID, value.GameVehicleID, value.ObservedAt.UTC(), value.CreatedAt.UTC())
|
||||
if err != nil {
|
||||
if isMySQLDuplicateKey(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("create scum_user_trajectory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserTrajectoryRepository) Get(id string) (domain.SCUMUserTrajectory, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
value, err := scanSCUMUserTrajectory(repository.db.QueryRowContext(ctx, `SELECT `+scumUserTrajectoryColumns+` FROM scum_user_trajectory WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.SCUMUserTrajectory{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.SCUMUserTrajectory{}, fmt.Errorf("read scum_user_trajectory: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserTrajectoryRepository) List(filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) {
|
||||
query := `SELECT ` + scumUserTrajectoryColumns + ` FROM scum_user_trajectory WHERE server_instance_id = ?`
|
||||
args := []any{filter.ServerInstanceID}
|
||||
if filter.SCUMUserID != "" {
|
||||
query += " AND scum_user_id = ?"
|
||||
args = append(args, filter.SCUMUserID)
|
||||
}
|
||||
if filter.SteamID != "" {
|
||||
query += " AND steam_id = ?"
|
||||
args = append(args, filter.SteamID)
|
||||
}
|
||||
if !filter.After.IsZero() {
|
||||
query += " AND observed_at > ?"
|
||||
args = append(args, filter.After.UTC())
|
||||
}
|
||||
query += " ORDER BY observed_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, scumListLimit(filter.Limit))
|
||||
return querySCUMUserTrajectories(repository.db, query, args)
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserTrajectoryRepository) Update(value domain.SCUMUserTrajectory) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `UPDATE scum_user_trajectory SET server_instance_id=?, scum_user_id=?, steam_id=?, display_name=?, x=?, y=?, z=?, ridden_vehicle_id=?, game_vehicle_id=?, observed_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMUserID, value.SteamID, value.DisplayName, value.X, value.Y, value.Z, value.RiddenVehicleID, value.GameVehicleID, value.ObservedAt.UTC(), value.CreatedAt.UTC(), value.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scum_user_trajectory: %w", err)
|
||||
}
|
||||
return scumUpdateResult(result, func() error {
|
||||
_, err := repository.Get(value.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMUserTrajectoryRepository) Delete(id string) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_user_trajectory WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete scum_user_trajectory: %w", err)
|
||||
}
|
||||
return scumDeleteResult(result)
|
||||
}
|
||||
|
||||
type mysqlSCUMVehicleTrajectoryRepository struct {
|
||||
db *sql.DB
|
||||
pruner *scumTrajectoryPruner
|
||||
}
|
||||
|
||||
const scumVehicleTrajectoryColumns = `id, server_instance_id, scum_vehicle_id, game_vehicle_id, vehicle_class, x, y, z, observed_at, created_at`
|
||||
|
||||
func (repository *mysqlSCUMVehicleTrajectoryRepository) Create(value domain.SCUMVehicleTrajectory) error {
|
||||
if err := repository.pruner.prune(repository.db, "scum_vehicle_trajectory", "observed_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle_trajectory (`+scumVehicleTrajectoryColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.VehicleClass, value.X, value.Y, value.Z, value.ObservedAt.UTC(), value.CreatedAt.UTC())
|
||||
if err != nil {
|
||||
if isMySQLDuplicateKey(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("create scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleTrajectoryRepository) Get(id string) (domain.SCUMVehicleTrajectory, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
value, err := scanSCUMVehicleTrajectory(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleTrajectoryColumns+` FROM scum_vehicle_trajectory WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.SCUMVehicleTrajectory{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.SCUMVehicleTrajectory{}, fmt.Errorf("read scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleTrajectoryRepository) List(filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) {
|
||||
query := `SELECT ` + scumVehicleTrajectoryColumns + ` FROM scum_vehicle_trajectory WHERE server_instance_id = ?`
|
||||
args := []any{filter.ServerInstanceID}
|
||||
if filter.SCUMVehicleID != "" {
|
||||
query += " AND scum_vehicle_id = ?"
|
||||
args = append(args, filter.SCUMVehicleID)
|
||||
}
|
||||
if filter.GameVehicleID != "" {
|
||||
query += " AND game_vehicle_id = ?"
|
||||
args = append(args, filter.GameVehicleID)
|
||||
}
|
||||
if !filter.After.IsZero() {
|
||||
query += " AND observed_at > ?"
|
||||
args = append(args, filter.After.UTC())
|
||||
}
|
||||
query += " ORDER BY observed_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, scumListLimit(filter.Limit))
|
||||
return querySCUMVehicleTrajectories(repository.db, query, args)
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleTrajectoryRepository) Update(value domain.SCUMVehicleTrajectory) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle_trajectory SET server_instance_id=?, scum_vehicle_id=?, game_vehicle_id=?, vehicle_class=?, x=?, y=?, z=?, observed_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.VehicleClass, value.X, value.Y, value.Z, value.ObservedAt.UTC(), value.CreatedAt.UTC(), value.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
return scumUpdateResult(result, func() error {
|
||||
_, err := repository.Get(value.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleTrajectoryRepository) Delete(id string) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle_trajectory WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
return scumDeleteResult(result)
|
||||
}
|
||||
|
||||
type mysqlSCUMVehicleLockRepository struct{ db *sql.DB }
|
||||
|
||||
const scumVehicleLockColumns = `id, server_instance_id, scum_vehicle_id, game_vehicle_id, scum_user_id, steam_id, locked_at, created_at`
|
||||
|
||||
func (repository *mysqlSCUMVehicleLockRepository) Create(value domain.SCUMVehicleLock) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle_lock (`+scumVehicleLockColumns+`) VALUES (?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.SCUMUserID, value.SteamID, value.LockedAt.UTC(), value.CreatedAt.UTC())
|
||||
if err != nil {
|
||||
if isMySQLDuplicateKey(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("create scum_vehicle_lock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleLockRepository) Get(id string) (domain.SCUMVehicleLock, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
value, err := scanSCUMVehicleLock(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleLockColumns+` FROM scum_vehicle_lock WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.SCUMVehicleLock{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.SCUMVehicleLock{}, fmt.Errorf("read scum_vehicle_lock: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleLockRepository) List(filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) {
|
||||
query := `SELECT ` + scumVehicleLockColumns + ` FROM scum_vehicle_lock WHERE server_instance_id = ?`
|
||||
args := []any{filter.ServerInstanceID}
|
||||
if filter.SCUMVehicleID != "" {
|
||||
query += " AND scum_vehicle_id = ?"
|
||||
args = append(args, filter.SCUMVehicleID)
|
||||
}
|
||||
if filter.GameVehicleID != "" {
|
||||
query += " AND game_vehicle_id = ?"
|
||||
args = append(args, filter.GameVehicleID)
|
||||
}
|
||||
if filter.SCUMUserID != "" {
|
||||
query += " AND scum_user_id = ?"
|
||||
args = append(args, filter.SCUMUserID)
|
||||
}
|
||||
if filter.SteamID != "" {
|
||||
query += " AND steam_id = ?"
|
||||
args = append(args, filter.SteamID)
|
||||
}
|
||||
if !filter.After.IsZero() {
|
||||
query += " AND locked_at > ?"
|
||||
args = append(args, filter.After.UTC())
|
||||
}
|
||||
query += " ORDER BY locked_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, scumListLimit(filter.Limit))
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
rows, err := repository.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle_lock: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]domain.SCUMVehicleLock, 0, 16)
|
||||
for rows.Next() {
|
||||
value, err := scanSCUMVehicleLock(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan scum_vehicle_lock: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle_lock: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleLockRepository) Update(value domain.SCUMVehicleLock) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle_lock SET server_instance_id=?, scum_vehicle_id=?, game_vehicle_id=?, scum_user_id=?, steam_id=?, locked_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.SCUMUserID, value.SteamID, value.LockedAt.UTC(), value.CreatedAt.UTC(), value.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update scum_vehicle_lock: %w", err)
|
||||
}
|
||||
return scumUpdateResult(result, func() error {
|
||||
_, err := repository.Get(value.ID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (repository *mysqlSCUMVehicleLockRepository) Delete(id string) error {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle_lock WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete scum_vehicle_lock: %w", err)
|
||||
}
|
||||
return scumDeleteResult(result)
|
||||
}
|
||||
|
||||
type scumTrajectoryPruner struct {
|
||||
mu sync.Mutex
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
// prune drops trajectory samples past the retention window. It runs at most
|
||||
// once per hour from the trajectory write path so the delete cannot dominate
|
||||
// ingest work.
|
||||
func (pruner *scumTrajectoryPruner) prune(db *sql.DB, table string, timeColumn string) error {
|
||||
pruner.mu.Lock()
|
||||
defer pruner.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
if !pruner.lastPrune.IsZero() && now.Sub(pruner.lastPrune) < scumTrajectoryPruneEvery {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+timeColumn+" < ?", now.Add(-scumTrajectoryRetention)); err != nil {
|
||||
return fmt.Errorf("prune %s: %w", table, err)
|
||||
}
|
||||
pruner.lastPrune = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// mysqlDSNWithParseTime keeps DATETIME columns readable as time.Time even when
|
||||
// the operator-provided DSN omits the driver flag.
|
||||
func mysqlDSNWithParseTime(dsn string) string {
|
||||
if strings.Contains(dsn, "parseTime=") {
|
||||
return dsn
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dsn + separator + "parseTime=true"
|
||||
}
|
||||
|
||||
func scumQueryContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
func scumListLimit(requested int) int {
|
||||
if requested <= 0 {
|
||||
return domain.SCUMDefaultListLimit
|
||||
}
|
||||
if requested > scumMaxListLimit {
|
||||
return scumMaxListLimit
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func isMySQLDuplicateKey(err error) bool {
|
||||
var mysqlError *mysql.MySQLError
|
||||
return errors.As(err, &mysqlError) && mysqlError.Number == 1062
|
||||
}
|
||||
|
||||
func scumUpdateResult(result sql.Result, confirm func() error) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return confirm()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scumDeleteResult(result sql.Result) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scumTimeValue(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func scumTimeFromNull(value sql.NullTime) time.Time {
|
||||
if !value.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.Time.UTC()
|
||||
}
|
||||
|
||||
func scumFloatValue(value *float64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func scumFloatFromNull(value sql.NullFloat64) *float64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
copied := value.Float64
|
||||
return &copied
|
||||
}
|
||||
|
||||
func scumIntValue(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func scumIntFromNull(value sql.NullInt64) *int64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
copied := value.Int64
|
||||
return &copied
|
||||
}
|
||||
|
||||
func scumUserArgs(value domain.SCUMUser) []any {
|
||||
return []any{
|
||||
value.ID, value.ServerInstanceID, value.SteamID, value.DisplayName, value.SquadID,
|
||||
scumTimeValue(value.RegisteredAt), scumTimeValue(value.LastLoginAt), scumTimeValue(value.LastActivityAt),
|
||||
value.LastLoginIP, value.Online, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
|
||||
scumIntValue(value.BankBalance), scumIntValue(value.GoldBars), value.RiddenVehicleID, value.GameVehicleID,
|
||||
scumTimeValue(value.WelcomeQueuedAt), value.WelcomeLastError, value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func scumUserUpdateArgs(value domain.SCUMUser) []any {
|
||||
args := scumUserArgs(value)
|
||||
return append(args[1:], args[0])
|
||||
}
|
||||
|
||||
func scumVehicleArgs(value domain.SCUMVehicle) []any {
|
||||
return []any{
|
||||
value.ID, value.ServerInstanceID, value.GameVehicleID, value.VehicleClass, value.DisplayName,
|
||||
value.Exists, value.Locked, value.Functional, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
|
||||
scumTimeValue(value.LastObservedAt), value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func scumVehicleUpdateArgs(value domain.SCUMVehicle) []any {
|
||||
args := scumVehicleArgs(value)
|
||||
return append(args[1:], args[0])
|
||||
}
|
||||
|
||||
type scumRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSCUMUser(scanner scumRowScanner) (domain.SCUMUser, error) {
|
||||
var (
|
||||
value domain.SCUMUser
|
||||
registeredAt, lastLoginAt, lastActivityAt sql.NullTime
|
||||
welcomeQueuedAt sql.NullTime
|
||||
x, y, z sql.NullFloat64
|
||||
bankBalance, goldBars sql.NullInt64
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&value.ID, &value.ServerInstanceID, &value.SteamID, &value.DisplayName, &value.SquadID,
|
||||
®isteredAt, &lastLoginAt, &lastActivityAt, &value.LastLoginIP, &value.Online,
|
||||
&x, &y, &z, &bankBalance, &goldBars, &value.RiddenVehicleID, &value.GameVehicleID,
|
||||
&welcomeQueuedAt, &value.WelcomeLastError, &value.CreatedAt, &value.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SCUMUser{}, err
|
||||
}
|
||||
value.RegisteredAt = scumTimeFromNull(registeredAt)
|
||||
value.LastLoginAt = scumTimeFromNull(lastLoginAt)
|
||||
value.LastActivityAt = scumTimeFromNull(lastActivityAt)
|
||||
value.WelcomeQueuedAt = scumTimeFromNull(welcomeQueuedAt)
|
||||
value.X = scumFloatFromNull(x)
|
||||
value.Y = scumFloatFromNull(y)
|
||||
value.Z = scumFloatFromNull(z)
|
||||
value.BankBalance = scumIntFromNull(bankBalance)
|
||||
value.GoldBars = scumIntFromNull(goldBars)
|
||||
value.CreatedAt = value.CreatedAt.UTC()
|
||||
value.UpdatedAt = value.UpdatedAt.UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func scanSCUMVehicle(scanner scumRowScanner) (domain.SCUMVehicle, error) {
|
||||
var (
|
||||
value domain.SCUMVehicle
|
||||
x, y, z sql.NullFloat64
|
||||
lastObservedAt sql.NullTime
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&value.ID, &value.ServerInstanceID, &value.GameVehicleID, &value.VehicleClass, &value.DisplayName,
|
||||
&value.Exists, &value.Locked, &value.Functional, &x, &y, &z, &lastObservedAt, &value.CreatedAt, &value.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SCUMVehicle{}, err
|
||||
}
|
||||
value.X = scumFloatFromNull(x)
|
||||
value.Y = scumFloatFromNull(y)
|
||||
value.Z = scumFloatFromNull(z)
|
||||
value.LastObservedAt = scumTimeFromNull(lastObservedAt)
|
||||
value.CreatedAt = value.CreatedAt.UTC()
|
||||
value.UpdatedAt = value.UpdatedAt.UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func scanSCUMUserTrajectory(scanner scumRowScanner) (domain.SCUMUserTrajectory, error) {
|
||||
var value domain.SCUMUserTrajectory
|
||||
err := scanner.Scan(
|
||||
&value.ID, &value.ServerInstanceID, &value.SCUMUserID, &value.SteamID, &value.DisplayName,
|
||||
&value.X, &value.Y, &value.Z, &value.RiddenVehicleID, &value.GameVehicleID, &value.ObservedAt, &value.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SCUMUserTrajectory{}, err
|
||||
}
|
||||
value.ObservedAt = value.ObservedAt.UTC()
|
||||
value.CreatedAt = value.CreatedAt.UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func scanSCUMVehicleTrajectory(scanner scumRowScanner) (domain.SCUMVehicleTrajectory, error) {
|
||||
var value domain.SCUMVehicleTrajectory
|
||||
err := scanner.Scan(
|
||||
&value.ID, &value.ServerInstanceID, &value.SCUMVehicleID, &value.GameVehicleID, &value.VehicleClass,
|
||||
&value.X, &value.Y, &value.Z, &value.ObservedAt, &value.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SCUMVehicleTrajectory{}, err
|
||||
}
|
||||
value.ObservedAt = value.ObservedAt.UTC()
|
||||
value.CreatedAt = value.CreatedAt.UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func scanSCUMVehicleLock(scanner scumRowScanner) (domain.SCUMVehicleLock, error) {
|
||||
var value domain.SCUMVehicleLock
|
||||
err := scanner.Scan(
|
||||
&value.ID, &value.ServerInstanceID, &value.SCUMVehicleID, &value.GameVehicleID, &value.SCUMUserID,
|
||||
&value.SteamID, &value.LockedAt, &value.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.SCUMVehicleLock{}, err
|
||||
}
|
||||
value.LockedAt = value.LockedAt.UTC()
|
||||
value.CreatedAt = value.CreatedAt.UTC()
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func querySCUMUserTrajectories(db *sql.DB, query string, args []any) ([]domain.SCUMUserTrajectory, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scum_user_trajectory: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]domain.SCUMUserTrajectory, 0, 16)
|
||||
for rows.Next() {
|
||||
value, err := scanSCUMUserTrajectory(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan scum_user_trajectory: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list scum_user_trajectory: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func querySCUMVehicleTrajectories(db *sql.DB, query string, args []any) ([]domain.SCUMVehicleTrajectory, error) {
|
||||
ctx, cancel := scumQueryContext()
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]domain.SCUMVehicleTrajectory, 0, 16)
|
||||
for rows.Next() {
|
||||
value, err := scanSCUMVehicleTrajectory(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list scum_vehicle_trajectory: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
+161
-67
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,12 +15,18 @@ import (
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const mysqlSnapshotID = "current"
|
||||
const (
|
||||
mysqlSnapshotID = "current"
|
||||
mysqlRuntimeSnapshotID = "runtime"
|
||||
)
|
||||
|
||||
type MySQLStore struct {
|
||||
*MemoryStore
|
||||
db *sql.DB
|
||||
persistMu sync.Mutex
|
||||
db *sql.DB
|
||||
persistMu sync.Mutex
|
||||
runtimePersistMu sync.Mutex
|
||||
scumUserTrackPruner scumTrajectoryPruner
|
||||
scumVehicleTrackPruner scumTrajectoryPruner
|
||||
}
|
||||
|
||||
func NewMySQLStore(dsn string) (*MySQLStore, error) {
|
||||
@@ -27,7 +34,7 @@ func NewMySQLStore(dsn string) (*MySQLStore, error) {
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("PLATFORM_MYSQL_DSN is required when PLATFORM_STORAGE_BACKEND=mysql")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
db, err := sql.Open("mysql", mysqlDSNWithParseTime(dsn))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql metadata store: %w", err)
|
||||
}
|
||||
@@ -59,7 +66,7 @@ func (store *MySQLStore) AuthSessions() AuthSessionRepository {
|
||||
}
|
||||
|
||||
func (store *MySQLStore) RunControlSessions() RunControlSessionRepository {
|
||||
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persist}
|
||||
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AIProviders() AIProviderRepository {
|
||||
@@ -75,7 +82,7 @@ func (store *MySQLStore) ServerInstances() ServerInstanceRepository {
|
||||
}
|
||||
|
||||
func (store *MySQLStore) RunEndpoints() RunEndpointRepository {
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persist}
|
||||
return &persistentRepository[domain.RunEndpoint, domain.RunEndpointFilter]{repository: store.MemoryStore.runEndpoints, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) Jobs() JobRepository {
|
||||
@@ -101,36 +108,16 @@ func (store *MySQLStore) RunDistributions() RunDistributionRepository {
|
||||
return &persistentRepository[domain.RunDistribution, domain.RunDistributionFilter]{repository: store.MemoryStore.runDists, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ClientManagerDistributions() ClientManagerDistributionRepository {
|
||||
return &persistentRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]{repository: store.MemoryStore.clientDists, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ClientManagerInstallations() ClientManagerInstallationRepository {
|
||||
return &persistentRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]{repository: store.MemoryStore.clientInstalls, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ClientManagerSessions() ClientManagerSessionRepository {
|
||||
return &persistentRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]{repository: store.MemoryStore.clientSessions, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ClientManagerNonces() ClientManagerNonceRepository {
|
||||
return &persistentRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]{repository: store.MemoryStore.clientNonces, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) DependencyStatuses() DependencyStatusRepository {
|
||||
return &persistentRepository[domain.DependencyStatus, domain.DependencyStatusFilter]{repository: store.MemoryStore.dependencies, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
|
||||
return &persistentRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]{repository: store.MemoryStore.buildJobs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) RunUpdateJobs() RunUpdateJobRepository {
|
||||
return &persistentRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]{repository: store.MemoryStore.updateJobs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) LogStreams() LogStreamRepository {
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persistRuntime}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
||||
@@ -145,10 +132,6 @@ func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
|
||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return &persistentGameClientBridgeCommandRepository{
|
||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||
@@ -181,6 +164,9 @@ CREATE TABLE IF NOT EXISTS platform_metadata_snapshots (
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mysql metadata snapshot table: %w", err)
|
||||
}
|
||||
if err := store.initializeSCUMTables(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -191,7 +177,7 @@ func (store *MySQLStore) load() error {
|
||||
err := store.db.QueryRowContext(ctx, "SELECT snapshot_json FROM platform_metadata_snapshots WHERE id = ?", mysqlSnapshotID).Scan(&payload)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
return store.loadRuntime()
|
||||
}
|
||||
return fmt.Errorf("read mysql metadata snapshot: %w", err)
|
||||
}
|
||||
@@ -200,6 +186,79 @@ func (store *MySQLStore) load() error {
|
||||
return fmt.Errorf("decode mysql metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadSnapshot(snapshot)
|
||||
if err := store.migrateSnapshotSCUM(ctx, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.loadRuntime()
|
||||
}
|
||||
|
||||
// migrateSnapshotSCUM copies SCUM rows that an older build kept inside the
|
||||
// metadata snapshot into the typed SCUM tables. SCUM is no longer stored in
|
||||
// the snapshot, so this runs at most once for a snapshot written before the
|
||||
// typed tables existed.
|
||||
func (store *MySQLStore) migrateSnapshotSCUM(ctx context.Context, snapshot StoreSnapshot) error {
|
||||
if len(snapshot.SCUMUsers)+len(snapshot.SCUMVehicles)+len(snapshot.SCUMUserTrajectories)+len(snapshot.SCUMVehicleTrajectories)+len(snapshot.SCUMVehicleLocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
users := store.SCUMUsers()
|
||||
for _, value := range snapshot.SCUMUsers {
|
||||
if _, err := users.Get(value.ID); err == nil {
|
||||
continue
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := users.Create(value); err != nil && !errors.Is(err, ErrDuplicate) {
|
||||
return fmt.Errorf("migrate scum_user %s: %w", value.ID, err)
|
||||
}
|
||||
}
|
||||
vehicles := store.SCUMVehicles()
|
||||
for _, value := range snapshot.SCUMVehicles {
|
||||
if _, err := vehicles.Get(value.ID); err == nil {
|
||||
continue
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := vehicles.Create(value); err != nil && !errors.Is(err, ErrDuplicate) {
|
||||
return fmt.Errorf("migrate scum_vehicle %s: %w", value.ID, err)
|
||||
}
|
||||
}
|
||||
userTracks := store.SCUMUserTrajectories()
|
||||
for _, value := range snapshot.SCUMUserTrajectories {
|
||||
if err := userTracks.Create(value); err != nil && !errors.Is(err, ErrDuplicate) {
|
||||
return fmt.Errorf("migrate scum_user_trajectory %s: %w", value.ID, err)
|
||||
}
|
||||
}
|
||||
vehicleTracks := store.SCUMVehicleTrajectories()
|
||||
for _, value := range snapshot.SCUMVehicleTrajectories {
|
||||
if err := vehicleTracks.Create(value); err != nil && !errors.Is(err, ErrDuplicate) {
|
||||
return fmt.Errorf("migrate scum_vehicle_trajectory %s: %w", value.ID, err)
|
||||
}
|
||||
}
|
||||
locks := store.SCUMVehicleLocks()
|
||||
for _, value := range snapshot.SCUMVehicleLocks {
|
||||
if err := locks.Create(value); err != nil && !errors.Is(err, ErrDuplicate) {
|
||||
return fmt.Errorf("migrate scum_vehicle_lock %s: %w", value.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) loadRuntime() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var payload []byte
|
||||
err := store.db.QueryRowContext(ctx, "SELECT snapshot_json FROM platform_metadata_snapshots WHERE id = ?", mysqlRuntimeSnapshotID).Scan(&payload)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read mysql runtime metadata snapshot: %w", err)
|
||||
}
|
||||
var snapshot runtimeSnapshot
|
||||
if err := json.Unmarshal(payload, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode mysql runtime metadata snapshot: %w", err)
|
||||
}
|
||||
store.loadRuntimeSnapshot(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -224,40 +283,70 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) persistRuntime() error {
|
||||
store.runtimePersistMu.Lock()
|
||||
defer store.runtimePersistMu.Unlock()
|
||||
|
||||
snapshot := store.runtimeSnapshot()
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode mysql runtime metadata snapshot: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = store.db.ExecContext(ctx, `
|
||||
INSERT INTO platform_metadata_snapshots (id, snapshot_json)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysqlRuntimeSnapshotID, string(payload), string(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write mysql runtime metadata snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MySQLStore) snapshot() StoreSnapshot {
|
||||
return StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
|
||||
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
|
||||
RunDistributions: snapshotRepository(store.MemoryStore.runDists),
|
||||
ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists),
|
||||
ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls),
|
||||
ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions),
|
||||
ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces),
|
||||
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
|
||||
ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
|
||||
return normalizeStoreSnapshot(StoreSnapshot{
|
||||
Users: snapshotRepository(store.MemoryStore.users),
|
||||
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
|
||||
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
|
||||
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
|
||||
Artifacts: snapshotRepository(store.MemoryStore.artifacts),
|
||||
RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings),
|
||||
EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys),
|
||||
RunDistributions: snapshotRepository(store.MemoryStore.runDists),
|
||||
DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies),
|
||||
RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||
PluginDataRecords: snapshotRepository(store.MemoryStore.pluginDataRecords),
|
||||
// SCUM rows live in the typed scum_* tables, never in the metadata snapshot.
|
||||
SCUMUsers: nil,
|
||||
SCUMUserTrajectories: nil,
|
||||
SCUMVehicles: nil,
|
||||
SCUMVehicleTrajectories: nil,
|
||||
SCUMVehicleLocks: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
|
||||
return runtimeSnapshot{
|
||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
snapshot = normalizeStoreSnapshot(snapshot)
|
||||
loadRepository(store.MemoryStore.users, snapshot.Users)
|
||||
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
|
||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||
@@ -270,20 +359,25 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
||||
loadRepository(store.MemoryStore.runtimeBindings, snapshot.RuntimeBindings)
|
||||
loadRepository(store.MemoryStore.componentKeys, snapshot.EncryptedComponentKeys)
|
||||
loadRepository(store.MemoryStore.runDists, snapshot.RunDistributions)
|
||||
loadRepository(store.MemoryStore.clientDists, snapshot.ClientManagerDistributions)
|
||||
loadRepository(store.MemoryStore.clientInstalls, snapshot.ClientManagerInstallations)
|
||||
loadRepository(store.MemoryStore.clientSessions, snapshot.ClientManagerSessions)
|
||||
loadRepository(store.MemoryStore.clientNonces, snapshot.ClientManagerNonces)
|
||||
loadRepository(store.MemoryStore.dependencies, snapshot.DependencyStatuses)
|
||||
loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs)
|
||||
loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||
loadRepository(store.MemoryStore.pluginDataRecords, snapshot.PluginDataRecords)
|
||||
loadRepository(store.MemoryStore.scumUsers, snapshot.SCUMUsers)
|
||||
loadRepository(store.MemoryStore.scumUserTracks, snapshot.SCUMUserTrajectories)
|
||||
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
|
||||
loadRepository(store.MemoryStore.scumVehicleTracks, snapshot.SCUMVehicleTrajectories)
|
||||
loadRepository(store.MemoryStore.scumVehicleLocks, snapshot.SCUMVehicleLocks)
|
||||
}
|
||||
|
||||
func (store *MySQLStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||
}
|
||||
|
||||
+168
-146
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
@@ -46,6 +47,7 @@ type GamePluginRepository interface {
|
||||
Get(id string) (domain.GamePlugin, error)
|
||||
List(domain.GamePluginFilter) ([]domain.GamePlugin, error)
|
||||
Update(domain.GamePlugin) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type ServerInstanceRepository interface {
|
||||
@@ -69,6 +71,7 @@ type JobRepository interface {
|
||||
GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error)
|
||||
List(domain.JobFilter) ([]domain.Job, error)
|
||||
Update(domain.Job) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type ArtifactRepository interface {
|
||||
@@ -99,35 +102,6 @@ type RunDistributionRepository interface {
|
||||
Update(domain.RunDistribution) error
|
||||
}
|
||||
|
||||
type ClientManagerDistributionRepository interface {
|
||||
Create(domain.ClientManagerDistribution) error
|
||||
Get(id string) (domain.ClientManagerDistribution, error)
|
||||
List(domain.ClientManagerDistributionFilter) ([]domain.ClientManagerDistribution, error)
|
||||
Update(domain.ClientManagerDistribution) error
|
||||
}
|
||||
|
||||
type ClientManagerInstallationRepository interface {
|
||||
Create(domain.ClientManagerInstallation) error
|
||||
Get(id string) (domain.ClientManagerInstallation, error)
|
||||
List(domain.ClientManagerInstallationFilter) ([]domain.ClientManagerInstallation, error)
|
||||
Update(domain.ClientManagerInstallation) error
|
||||
}
|
||||
|
||||
type ClientManagerSessionRepository interface {
|
||||
Create(domain.ClientManagerSession) error
|
||||
Get(id string) (domain.ClientManagerSession, error)
|
||||
List(domain.ClientManagerSessionFilter) ([]domain.ClientManagerSession, error)
|
||||
Update(domain.ClientManagerSession) error
|
||||
}
|
||||
|
||||
type ClientManagerNonceRepository interface {
|
||||
Create(domain.ClientManagerRegistrationNonce) error
|
||||
Get(id string) (domain.ClientManagerRegistrationNonce, error)
|
||||
List(domain.ClientManagerNonceFilter) ([]domain.ClientManagerRegistrationNonce, error)
|
||||
Update(domain.ClientManagerRegistrationNonce) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type DependencyStatusRepository interface {
|
||||
Create(domain.DependencyStatus) error
|
||||
Get(id string) (domain.DependencyStatus, error)
|
||||
@@ -135,13 +109,6 @@ type DependencyStatusRepository interface {
|
||||
Update(domain.DependencyStatus) error
|
||||
}
|
||||
|
||||
type ClientManagerBuildJobRepository interface {
|
||||
Create(domain.ClientManagerBuildJob) error
|
||||
Get(id string) (domain.ClientManagerBuildJob, error)
|
||||
List(domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error)
|
||||
Update(domain.ClientManagerBuildJob) error
|
||||
}
|
||||
|
||||
type RunUpdateJobRepository interface {
|
||||
Create(domain.RunUpdateJob) error
|
||||
Get(id string) (domain.RunUpdateJob, error)
|
||||
@@ -154,6 +121,7 @@ type LogStreamRepository interface {
|
||||
Get(id string) (domain.LogStream, error)
|
||||
List(domain.LogStreamFilter) ([]domain.LogStream, error)
|
||||
Update(domain.LogStream) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type MetricSampleRepository interface {
|
||||
@@ -179,13 +147,6 @@ type PluginLifecycleRepository interface {
|
||||
Update(domain.PluginLifecycleInstallation) error
|
||||
}
|
||||
|
||||
type AIConfigDiffRepository interface {
|
||||
Create(domain.AIConfigDiffPreview) error
|
||||
Get(id string) (domain.AIConfigDiffPreview, error)
|
||||
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
||||
Update(domain.AIConfigDiffPreview) error
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandRepository interface {
|
||||
Create(domain.GameClientBridgeCommand) error
|
||||
Get(id string) (domain.GameClientBridgeCommand, error)
|
||||
@@ -220,6 +181,46 @@ type PluginDataRecordRepository interface {
|
||||
Apply([]domain.PluginDataRecord, []string) error
|
||||
}
|
||||
|
||||
type SCUMUserRepository interface {
|
||||
Create(domain.SCUMUser) error
|
||||
Get(string) (domain.SCUMUser, error)
|
||||
List(domain.SCUMUserFilter) ([]domain.SCUMUser, error)
|
||||
Update(domain.SCUMUser) error
|
||||
Delete(string) error
|
||||
}
|
||||
|
||||
type SCUMUserTrajectoryRepository interface {
|
||||
Create(domain.SCUMUserTrajectory) error
|
||||
Get(string) (domain.SCUMUserTrajectory, error)
|
||||
List(domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error)
|
||||
Update(domain.SCUMUserTrajectory) error
|
||||
Delete(string) error
|
||||
}
|
||||
|
||||
type SCUMVehicleRepository interface {
|
||||
Create(domain.SCUMVehicle) error
|
||||
Get(string) (domain.SCUMVehicle, error)
|
||||
List(domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error)
|
||||
Update(domain.SCUMVehicle) error
|
||||
Delete(string) error
|
||||
}
|
||||
|
||||
type SCUMVehicleTrajectoryRepository interface {
|
||||
Create(domain.SCUMVehicleTrajectory) error
|
||||
Get(string) (domain.SCUMVehicleTrajectory, error)
|
||||
List(domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error)
|
||||
Update(domain.SCUMVehicleTrajectory) error
|
||||
Delete(string) error
|
||||
}
|
||||
|
||||
type SCUMVehicleLockRepository interface {
|
||||
Create(domain.SCUMVehicleLock) error
|
||||
Get(string) (domain.SCUMVehicleLock, error)
|
||||
List(domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error)
|
||||
Update(domain.SCUMVehicleLock) error
|
||||
Delete(string) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
AuthSessions() AuthSessionRepository
|
||||
@@ -233,22 +234,21 @@ type Store interface {
|
||||
RuntimeBindings() RuntimeBindingRepository
|
||||
EncryptedComponentKeys() EncryptedComponentKeyRepository
|
||||
RunDistributions() RunDistributionRepository
|
||||
ClientManagerDistributions() ClientManagerDistributionRepository
|
||||
ClientManagerInstallations() ClientManagerInstallationRepository
|
||||
ClientManagerSessions() ClientManagerSessionRepository
|
||||
ClientManagerNonces() ClientManagerNonceRepository
|
||||
DependencyStatuses() DependencyStatusRepository
|
||||
ClientManagerBuildJobs() ClientManagerBuildJobRepository
|
||||
RunUpdateJobs() RunUpdateJobRepository
|
||||
LogStreams() LogStreamRepository
|
||||
MetricSamples() MetricSampleRepository
|
||||
Backups() BackupRepository
|
||||
PluginLifecycles() PluginLifecycleRepository
|
||||
AIConfigDiffs() AIConfigDiffRepository
|
||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
||||
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
||||
PluginDataRecords() PluginDataRecordRepository
|
||||
SCUMUsers() SCUMUserRepository
|
||||
SCUMUserTrajectories() SCUMUserTrajectoryRepository
|
||||
SCUMVehicles() SCUMVehicleRepository
|
||||
SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository
|
||||
SCUMVehicleLocks() SCUMVehicleLockRepository
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
@@ -264,22 +264,21 @@ type MemoryStore struct {
|
||||
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
|
||||
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
|
||||
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
|
||||
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
|
||||
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
|
||||
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
|
||||
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
|
||||
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
|
||||
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
|
||||
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
|
||||
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
|
||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||
pluginDataRecords *memoryRepository[domain.PluginDataRecord, domain.PluginDataFilter]
|
||||
scumUsers *memoryRepository[domain.SCUMUser, domain.SCUMUserFilter]
|
||||
scumUserTracks *memoryRepository[domain.SCUMUserTrajectory, domain.SCUMUserTrajectoryFilter]
|
||||
scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMVehicleFilter]
|
||||
scumVehicleTracks *memoryRepository[domain.SCUMVehicleTrajectory, domain.SCUMVehicleTrajectoryFilter]
|
||||
scumVehicleLocks *memoryRepository[domain.SCUMVehicleLock, domain.SCUMVehicleLockFilter]
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -340,36 +339,11 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyRunDistribution,
|
||||
matchRunDistribution,
|
||||
),
|
||||
clientDists: newMemoryRepository(
|
||||
func(distribution domain.ClientManagerDistribution) string { return distribution.ID },
|
||||
domain.CopyClientManagerDistribution,
|
||||
matchClientManagerDistribution,
|
||||
),
|
||||
clientInstalls: newMemoryRepository(
|
||||
func(installation domain.ClientManagerInstallation) string { return installation.ID },
|
||||
domain.CopyClientManagerInstallation,
|
||||
matchClientManagerInstallation,
|
||||
),
|
||||
clientSessions: newMemoryRepository(
|
||||
func(session domain.ClientManagerSession) string { return session.ID },
|
||||
domain.CopyClientManagerSession,
|
||||
matchClientManagerSession,
|
||||
),
|
||||
clientNonces: newMemoryRepository(
|
||||
func(nonce domain.ClientManagerRegistrationNonce) string { return nonce.ID },
|
||||
domain.CopyClientManagerRegistrationNonce,
|
||||
matchClientManagerNonce,
|
||||
),
|
||||
dependencies: newMemoryRepository(
|
||||
func(status domain.DependencyStatus) string { return status.ID },
|
||||
domain.CopyDependencyStatus,
|
||||
matchDependencyStatus,
|
||||
),
|
||||
buildJobs: newMemoryRepository(
|
||||
func(job domain.ClientManagerBuildJob) string { return job.ID },
|
||||
domain.CopyClientManagerBuildJob,
|
||||
matchClientManagerBuildJob,
|
||||
),
|
||||
updateJobs: newMemoryRepository(
|
||||
func(job domain.RunUpdateJob) string { return job.ID },
|
||||
domain.CopyRunUpdateJob,
|
||||
@@ -395,11 +369,6 @@ func NewMemoryStore() *MemoryStore {
|
||||
domain.CopyPluginLifecycleInstallation,
|
||||
matchPluginLifecycle,
|
||||
),
|
||||
aiConfigDiffs: newMemoryRepository(
|
||||
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
|
||||
domain.CopyAIConfigDiffPreview,
|
||||
matchAIConfigDiff,
|
||||
),
|
||||
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
||||
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
||||
bridgeStreams: newMemoryRepository(
|
||||
@@ -408,6 +377,11 @@ func NewMemoryStore() *MemoryStore {
|
||||
matchGameClientBridgeSnapshotStream,
|
||||
),
|
||||
pluginDataRecords: newMemoryRepository(func(value domain.PluginDataRecord) string { return value.ID }, domain.CopyPluginDataRecord, matchPluginDataRecord),
|
||||
scumUsers: newMemoryRepository(func(value domain.SCUMUser) string { return value.ID }, domain.CopySCUMUser, matchSCUMUser),
|
||||
scumUserTracks: newMemoryRepository(func(value domain.SCUMUserTrajectory) string { return value.ID }, domain.CopySCUMUserTrajectory, matchSCUMUserTrajectory),
|
||||
scumVehicles: newMemoryRepository(func(value domain.SCUMVehicle) string { return value.ID }, domain.CopySCUMVehicle, matchSCUMVehicle),
|
||||
scumVehicleTracks: newMemoryRepository(func(value domain.SCUMVehicleTrajectory) string { return value.ID }, domain.CopySCUMVehicleTrajectory, matchSCUMVehicleTrajectory),
|
||||
scumVehicleLocks: newMemoryRepository(func(value domain.SCUMVehicleLock) string { return value.ID }, domain.CopySCUMVehicleLock, matchSCUMVehicleLock),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,31 +398,15 @@ func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { ret
|
||||
func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository {
|
||||
return store.componentKeys
|
||||
}
|
||||
func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists }
|
||||
func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository {
|
||||
return store.clientDists
|
||||
}
|
||||
func (store *MemoryStore) ClientManagerInstallations() ClientManagerInstallationRepository {
|
||||
return store.clientInstalls
|
||||
}
|
||||
func (store *MemoryStore) ClientManagerSessions() ClientManagerSessionRepository {
|
||||
return store.clientSessions
|
||||
}
|
||||
func (store *MemoryStore) ClientManagerNonces() ClientManagerNonceRepository {
|
||||
return store.clientNonces
|
||||
}
|
||||
func (store *MemoryStore) RunDistributions() RunDistributionRepository { return store.runDists }
|
||||
func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies }
|
||||
func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
|
||||
return store.buildJobs
|
||||
}
|
||||
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
||||
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
||||
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
||||
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
||||
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
|
||||
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
|
||||
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
|
||||
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
|
||||
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||
return store.pluginLifecycle
|
||||
}
|
||||
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
|
||||
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||
return store.bridgeCommands
|
||||
}
|
||||
@@ -461,6 +419,15 @@ func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnap
|
||||
func (store *MemoryStore) PluginDataRecords() PluginDataRecordRepository {
|
||||
return store.pluginDataRecords
|
||||
}
|
||||
func (store *MemoryStore) SCUMUsers() SCUMUserRepository { return store.scumUsers }
|
||||
func (store *MemoryStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository {
|
||||
return store.scumUserTracks
|
||||
}
|
||||
func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles }
|
||||
func (store *MemoryStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository {
|
||||
return store.scumVehicleTracks
|
||||
}
|
||||
func (store *MemoryStore) SCUMVehicleLocks() SCUMVehicleLockRepository { return store.scumVehicleLocks }
|
||||
|
||||
type memoryRepository[T any, F any] struct {
|
||||
mu sync.RWMutex
|
||||
@@ -572,6 +539,39 @@ func newMemoryJobRepository() *memoryJobRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func (repository *memoryJobRepository) List(filter domain.JobFilter) ([]domain.Job, error) {
|
||||
if filter.Limit <= 0 {
|
||||
return repository.memoryRepository.List(filter)
|
||||
}
|
||||
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
|
||||
values := make([]domain.Job, 0, min(filter.Limit, len(repository.byID)))
|
||||
for _, job := range repository.byID {
|
||||
if repository.match(job, filter) {
|
||||
values = append(values, domain.CopyJob(job))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(values, func(i, j int) bool {
|
||||
leftUpdated := values[i].UpdatedAt
|
||||
rightUpdated := values[j].UpdatedAt
|
||||
if !leftUpdated.Equal(rightUpdated) {
|
||||
return leftUpdated.After(rightUpdated)
|
||||
}
|
||||
leftCreated := values[i].CreatedAt
|
||||
rightCreated := values[j].CreatedAt
|
||||
if !leftCreated.Equal(rightCreated) {
|
||||
return leftCreated.After(rightCreated)
|
||||
}
|
||||
return values[i].ID < values[j].ID
|
||||
})
|
||||
if len(values) > filter.Limit {
|
||||
values = values[:filter.Limit]
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (repository *memoryJobRepository) GetByIdempotency(runEndpointID string, idempotencyKey string) (domain.Job, error) {
|
||||
repository.mu.RLock()
|
||||
defer repository.mu.RUnlock()
|
||||
@@ -630,7 +630,22 @@ func matchRunEndpoint(endpoint domain.RunEndpoint, filter domain.RunEndpointFilt
|
||||
func matchJob(job domain.Job, filter domain.JobFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.RunEndpointID == "" || job.RunEndpointID == filter.RunEndpointID) &&
|
||||
(filter.State == "" || job.State == filter.State)
|
||||
matchJobState(job.State, filter)
|
||||
}
|
||||
|
||||
func matchJobState(state domain.JobState, filter domain.JobFilter) bool {
|
||||
if filter.State != "" && state != filter.State {
|
||||
return false
|
||||
}
|
||||
if len(filter.States) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range filter.States {
|
||||
if state == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchArtifact(artifact domain.Artifact, filter domain.ArtifactFilter) bool {
|
||||
@@ -659,45 +674,12 @@ func matchRunDistribution(distribution domain.RunDistribution, filter domain.Run
|
||||
(filter.Status == "" || distribution.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchClientManagerDistribution(distribution domain.ClientManagerDistribution, filter domain.ClientManagerDistributionFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || distribution.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.ProfileKey == "" || distribution.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.TargetOS == "" || distribution.TargetOS == filter.TargetOS) &&
|
||||
(filter.TargetArch == "" || distribution.TargetArch == filter.TargetArch) &&
|
||||
(filter.Status == "" || distribution.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchClientManagerInstallation(installation domain.ClientManagerInstallation, filter domain.ClientManagerInstallationFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.ProfileKey == "" || installation.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.RunEndpointID == "" || installation.RunEndpointID == filter.RunEndpointID) &&
|
||||
(filter.Status == "" || installation.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchClientManagerSession(session domain.ClientManagerSession, filter domain.ClientManagerSessionFilter) bool {
|
||||
return (filter.InstallationID == "" || session.InstallationID == filter.InstallationID) &&
|
||||
(filter.ServerInstanceID == "" || session.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.ProfileKey == "" || session.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.Status == "" || session.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchClientManagerNonce(nonce domain.ClientManagerRegistrationNonce, filter domain.ClientManagerNonceFilter) bool {
|
||||
return (filter.InstallationID == "" || nonce.InstallationID == filter.InstallationID) &&
|
||||
(filter.ExpiresBefore.IsZero() || nonce.ExpiresAt.Before(filter.ExpiresBefore) || nonce.ExpiresAt.Equal(filter.ExpiresBefore))
|
||||
}
|
||||
|
||||
func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) &&
|
||||
(filter.State == "" || status.State == filter.State)
|
||||
}
|
||||
|
||||
func matchClientManagerBuildJob(job domain.ClientManagerBuildJob, filter domain.ClientManagerBuildJobFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.ProfileKey == "" || job.ProfileKey == filter.ProfileKey) &&
|
||||
(filter.Status == "" || job.Status == filter.Status)
|
||||
}
|
||||
|
||||
func matchRunUpdateJob(job domain.RunUpdateJob, filter domain.RunUpdateJobFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || job.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.Status == "" || job.Status == filter.Status)
|
||||
@@ -725,12 +707,6 @@ func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filte
|
||||
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
||||
}
|
||||
|
||||
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
|
||||
(filter.State == "" || preview.State == filter.State)
|
||||
}
|
||||
|
||||
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
||||
@@ -764,3 +740,49 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS
|
||||
func matchPluginDataRecord(value domain.PluginDataRecord, filter domain.PluginDataFilter) bool {
|
||||
return (filter.PluginID == "" || value.PluginID == filter.PluginID) && (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) && (filter.Collection == "" || value.Collection == filter.Collection) && (filter.Key == "" || value.Key == filter.Key)
|
||||
}
|
||||
|
||||
func matchSCUMUser(value domain.SCUMUser, filter domain.SCUMUserFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.SteamID == "" || value.SteamID == filter.SteamID) &&
|
||||
(filter.Online == nil || value.Online == *filter.Online) &&
|
||||
(filter.ChangedAfter.IsZero() || value.UpdatedAt.After(filter.ChangedAfter) || value.LastActivityAt.After(filter.ChangedAfter)) &&
|
||||
(filter.StaleBefore.IsZero() || scumUserActivityAt(value).Before(filter.StaleBefore))
|
||||
}
|
||||
|
||||
// scumUserActivityAt reports the timestamp used for offline convergence.
|
||||
func scumUserActivityAt(value domain.SCUMUser) time.Time {
|
||||
if value.LastActivityAt.IsZero() {
|
||||
return value.UpdatedAt
|
||||
}
|
||||
return value.LastActivityAt
|
||||
}
|
||||
|
||||
func matchSCUMUserTrajectory(value domain.SCUMUserTrajectory, filter domain.SCUMUserTrajectoryFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.SCUMUserID == "" || value.SCUMUserID == filter.SCUMUserID) &&
|
||||
(filter.SteamID == "" || value.SteamID == filter.SteamID) &&
|
||||
(filter.After.IsZero() || value.ObservedAt.After(filter.After))
|
||||
}
|
||||
|
||||
func matchSCUMVehicle(value domain.SCUMVehicle, filter domain.SCUMVehicleFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) &&
|
||||
(filter.Exists == nil || value.Exists == *filter.Exists) &&
|
||||
(filter.ChangedAfter.IsZero() || value.UpdatedAt.After(filter.ChangedAfter) || value.LastObservedAt.After(filter.ChangedAfter))
|
||||
}
|
||||
|
||||
func matchSCUMVehicleTrajectory(value domain.SCUMVehicleTrajectory, filter domain.SCUMVehicleTrajectoryFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.SCUMVehicleID == "" || value.SCUMVehicleID == filter.SCUMVehicleID) &&
|
||||
(filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) &&
|
||||
(filter.After.IsZero() || value.ObservedAt.After(filter.After))
|
||||
}
|
||||
|
||||
func matchSCUMVehicleLock(value domain.SCUMVehicleLock, filter domain.SCUMVehicleLockFilter) bool {
|
||||
return (filter.ServerInstanceID == "" || value.ServerInstanceID == filter.ServerInstanceID) &&
|
||||
(filter.SCUMVehicleID == "" || value.SCUMVehicleID == filter.SCUMVehicleID) &&
|
||||
(filter.GameVehicleID == "" || value.GameVehicleID == filter.GameVehicleID) &&
|
||||
(filter.SCUMUserID == "" || value.SCUMUserID == filter.SCUMUserID) &&
|
||||
(filter.SteamID == "" || value.SteamID == filter.SteamID) &&
|
||||
(filter.After.IsZero() || value.LockedAt.After(filter.After))
|
||||
}
|
||||
|
||||
+114
-14
@@ -1,6 +1,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -254,6 +255,62 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStoreNormalizesPluginVersionsAndPrunesLegacySCUMSnapshotData(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
stalePlugin := domain.GamePlugin{ID: "game.scum.codex.20260804095301", Name: "SCUM Server", Version: "0.1.4", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "plugins/examples/scum-server-plugin/game.scum.codex.20260804095301/manifest.json", Status: domain.GamePluginStatusInstalled}
|
||||
latestPlugin := domain.GamePlugin{ID: "game.scum", Name: "SCUM Server", Version: "0.1.15", ServerType: "scum", ServerDisplayName: "SCUM Dedicated Server", ManifestRef: "artifact://manifests/game.scum/0.1.15", Status: domain.GamePluginStatusInstalled}
|
||||
stamp := time.Date(2026, 9, 14, 9, 0, 0, 0, time.UTC)
|
||||
snapshot := StoreSnapshot{
|
||||
GamePlugins: []domain.GamePlugin{stalePlugin, latestPlugin},
|
||||
ServerInstances: []domain.ServerInstance{{ID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, Name: "SCUM", State: domain.ServerInstanceStateDraft, CreatedAt: stamp, UpdatedAt: stamp}},
|
||||
RuntimeBindings: []domain.RuntimeBinding{{ID: "runtime-binding-server-scum", ServerInstanceID: "server-scum", PluginID: stalePlugin.ID, PluginVersion: stalePlugin.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}},
|
||||
Jobs: []domain.Job{
|
||||
{ID: "job-scum-sqlite-query", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, IdempotencyKey: "legacy-query", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
|
||||
{ID: "job-scum-start", ServerInstanceID: "server-scum", RunEndpointID: "run-local", Capability: domain.JobCapabilityRemoteRunProgram, IdempotencyKey: "start", State: domain.JobStateSucceeded, CreatedAt: stamp, UpdatedAt: stamp},
|
||||
},
|
||||
PluginDataRecords: []domain.PluginDataRecord{
|
||||
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_gifts", "starter"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts", Key: "starter", Value: map[string]any{"name": "Starter"}, CreatedAt: stamp, UpdatedAt: stamp},
|
||||
{ID: snapshotPluginDataID("server-scum", stalePlugin.ID, "scum_trajectories", "point-1"), PluginID: stalePlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_trajectories", Key: "point-1", Value: map[string]any{"source": "legacy"}, CreatedAt: stamp, UpdatedAt: stamp},
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale snapshot: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatalf("write stale snapshot: %v", err)
|
||||
}
|
||||
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload stale snapshot: %v", err)
|
||||
}
|
||||
plugins, err := store.GamePlugins().List(domain.GamePluginFilter{ServerType: "scum"})
|
||||
if err != nil || len(plugins) != 1 || plugins[0].ID != latestPlugin.ID || plugins[0].Version != latestPlugin.Version {
|
||||
t.Fatalf("expected only latest plugin, plugins=%+v err=%v", plugins, err)
|
||||
}
|
||||
server, err := store.ServerInstances().Get("server-scum")
|
||||
if err != nil || server.PluginID != latestPlugin.ID || server.PluginVersion != latestPlugin.Version {
|
||||
t.Fatalf("expected server reference migration, server=%+v err=%v", server, err)
|
||||
}
|
||||
binding, err := store.RuntimeBindings().Get("runtime-binding-server-scum")
|
||||
if err != nil || binding.PluginID != latestPlugin.ID || binding.PluginVersion != latestPlugin.Version {
|
||||
t.Fatalf("expected runtime binding migration, binding=%+v err=%v", binding, err)
|
||||
}
|
||||
config, err := store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: latestPlugin.ID, ServerInstanceID: "server-scum", Collection: "scum_gifts"})
|
||||
if err != nil || len(config) != 1 || config[0].Key != "starter" {
|
||||
t.Fatalf("expected plugin-owned config data to migrate, data=%+v err=%v", config, err)
|
||||
}
|
||||
legacy, err := store.PluginDataRecords().List(domain.PluginDataFilter{ServerInstanceID: "server-scum", Collection: "scum_trajectories"})
|
||||
if err != nil || len(legacy) != 0 {
|
||||
t.Fatalf("expected legacy SCUM projection data to be pruned, data=%+v err=%v", legacy, err)
|
||||
}
|
||||
jobs, err := store.Jobs().List(domain.JobFilter{ServerInstanceID: "server-scum"})
|
||||
if err != nil || len(jobs) != 2 {
|
||||
t.Fatalf("expected plugin-declared SCUM sqlite query job to survive the snapshot, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
|
||||
source := &MySQLStore{MemoryStore: NewMemoryStore()}
|
||||
@@ -306,6 +363,63 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStorePersistsRunRuntimeStateSeparately(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
store, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create file store: %v", err)
|
||||
}
|
||||
stamp := time.Date(2026, 7, 18, 10, 0, 0, 0, time.UTC)
|
||||
if err := store.PluginDataRecords().Create(domain.PluginDataRecord{ID: "plugin-record-1", PluginID: "server.scum", ServerInstanceID: "server-1", Collection: "players", Key: "steam-1", Value: map[string]any{"displayName": "Ada"}, CreatedAt: stamp, UpdatedAt: stamp}); err != nil {
|
||||
t.Fatalf("create plugin data: %v", err)
|
||||
}
|
||||
mainBefore, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read main snapshot before runtime update: %v", err)
|
||||
}
|
||||
|
||||
endpoint := domain.RunEndpoint{ID: "run-runtime", DisplayName: "Runtime Run", Version: "1.0.0", Platform: "linux", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: stamp}
|
||||
if err := store.RunEndpoints().Create(endpoint); err != nil {
|
||||
t.Fatalf("create runtime endpoint: %v", err)
|
||||
}
|
||||
session := domain.RunControlSession{RunEndpointID: endpoint.ID, SessionToken: "raw-runtime-token", SessionTokenHash: strings.Repeat("a", 64), Status: domain.AuthSessionStatusActive, Generation: 1, CapabilityFingerprint: "cap-runtime", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true, UsedNonces: []string{"nonce-1"}}
|
||||
if err := store.RunControlSessions().Create(session); err != nil {
|
||||
t.Fatalf("create runtime session: %v", err)
|
||||
}
|
||||
stream := domain.LogStream{ID: "run.run-runtime.server-1.scum.console.stdout", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "scum.console.stdout", LatestSeq: 42, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := store.LogStreams().Create(stream); err != nil {
|
||||
t.Fatalf("create runtime log stream: %v", err)
|
||||
}
|
||||
mainAfter, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read main snapshot after runtime update: %v", err)
|
||||
}
|
||||
if !bytes.Equal(mainBefore, mainAfter) {
|
||||
t.Fatalf("runtime updates rewrote the main metadata snapshot")
|
||||
}
|
||||
runtimePayload, err := os.ReadFile(runtimeMetadataPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(runtimePayload), endpoint.ID) || !strings.Contains(string(runtimePayload), stream.ID) || strings.Contains(string(runtimePayload), session.SessionToken) {
|
||||
t.Fatalf("unexpected runtime snapshot payload: %s", runtimePayload)
|
||||
}
|
||||
|
||||
reloaded, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload file store: %v", err)
|
||||
}
|
||||
if got, err := reloaded.RunEndpoints().Get(endpoint.ID); err != nil || got.Version != endpoint.Version {
|
||||
t.Fatalf("runtime endpoint did not reload: %+v err=%v", got, err)
|
||||
}
|
||||
if got, err := reloaded.RunControlSessions().Get(endpoint.ID); err != nil || got.SessionTokenHash != session.SessionTokenHash || got.UsedNonces[0] != "nonce-1" {
|
||||
t.Fatalf("runtime session did not reload: %+v err=%v", got, err)
|
||||
}
|
||||
if got, err := reloaded.LogStreams().Get(stream.ID); err != nil || got.LatestSeq != stream.LatestSeq {
|
||||
t.Fatalf("runtime log stream did not reload: %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "plugin-operations.json")
|
||||
store, err := NewFileStore(path)
|
||||
@@ -321,19 +435,9 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
||||
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||
}
|
||||
diff := domain.AIConfigDiffPreview{
|
||||
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
||||
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
||||
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
||||
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
||||
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
||||
}
|
||||
if err := store.PluginLifecycles().Create(installation); err != nil {
|
||||
t.Fatalf("create plugin lifecycle: %v", err)
|
||||
}
|
||||
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
||||
t.Fatalf("create AI config diff: %v", err)
|
||||
}
|
||||
|
||||
restarted, err := NewFileStore(path)
|
||||
if err != nil {
|
||||
@@ -343,10 +447,6 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
||||
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
||||
}
|
||||
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
||||
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
||||
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
@@ -24,7 +23,7 @@ func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AI
|
||||
if model == "" && len(provider.Models) > 0 {
|
||||
model = provider.Models[0]
|
||||
}
|
||||
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
|
||||
recommendation := "Mock AI recommendation for " + request.Purpose + ": configuration changes are dispatched automatically."
|
||||
result := domain.AIProviderInvocationResult{
|
||||
Recommendation: recommendation,
|
||||
Usage: domain.AIInvocationUsage{
|
||||
@@ -46,10 +45,12 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
var currentConfig domain.ServerConfig
|
||||
var hasCurrentConfig bool
|
||||
var err error
|
||||
if request.ServerInstanceID != "" {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
@@ -63,6 +64,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
currentConfig = config
|
||||
hasCurrentConfig = true
|
||||
request.CurrentConfig = config.Content
|
||||
}
|
||||
}
|
||||
@@ -111,16 +114,25 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
||||
Usage: result.Usage,
|
||||
}
|
||||
if result.SuggestedConfig != "" {
|
||||
if request.ServerInstanceID == "" {
|
||||
if request.ServerInstanceID == "" || !hasCurrentConfig {
|
||||
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||
}
|
||||
idempotencyKey := aiConfigWriteIdempotencyKey(request.RequestID, request.ServerInstanceID)
|
||||
svc.pluginOperationsMu.Lock()
|
||||
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
|
||||
dispatch, dispatchErr := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
ExpectedConfigVersion: currentConfig.ConfigVersion,
|
||||
ExpectedChecksum: currentConfig.Checksum,
|
||||
Key: currentConfig.Key,
|
||||
ProposedContent: result.SuggestedConfig,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
svc.pluginOperationsMu.Unlock()
|
||||
if persistErr != nil {
|
||||
return domain.AIInvocationResponse{}, persistErr
|
||||
if dispatchErr != nil {
|
||||
return domain.AIInvocationResponse{}, dispatchErr
|
||||
}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: currentConfig.Key, SuggestedConfig: result.SuggestedConfig, DiffSummary: "AI 配置建议已直接派发 config.write 任务"}
|
||||
response.ConfigExecution = &domain.AIConfigExecution{Status: dispatch.Status, Job: dispatch.Job}
|
||||
}
|
||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
@@ -179,7 +191,7 @@ func buildSuggestedConfig(currentConfig string, prompt string) string {
|
||||
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
|
||||
base += "\npvp=false"
|
||||
}
|
||||
return base + "\n# ai.recommendation=review-required\n"
|
||||
return base + "\n# ai.recommendation=auto-applied\n"
|
||||
}
|
||||
|
||||
func boundedTokenEstimate(value string) int {
|
||||
|
||||
@@ -283,7 +283,7 @@ func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
||||
builder.WriteString(request.CurrentConfig)
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
|
||||
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes are dispatched automatically as a bounded config.write job.")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -22,8 +23,10 @@ type ArtifactBodyStore interface {
|
||||
SaveTransfer(domain.ArtifactTransferSession) error
|
||||
LoadTransfers() ([]domain.ArtifactTransferSession, error)
|
||||
PutPayload(string, []byte) error
|
||||
PutPayloadFromFile(string, string) error
|
||||
GetPayload(string) ([]byte, error)
|
||||
ReadPayloadRange(string, int64, int) ([]byte, error)
|
||||
OpenPayloadRange(string, int64, int64) (io.ReadCloser, error)
|
||||
CommitTransferPayload(domain.ArtifactTransferSession) error
|
||||
}
|
||||
|
||||
@@ -66,6 +69,14 @@ func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []by
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error {
|
||||
payload, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read artifact source file: %w", err)
|
||||
}
|
||||
return store.PutPayload(artifactID, payload)
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -89,6 +100,17 @@ func (store *MemoryArtifactBodyStore) ReadPayloadRange(artifactID string, offset
|
||||
return domain.CopyBytes(payload[int(offset) : int(offset)+length]), nil
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) OpenPayloadRange(artifactID string, offset int64, length int64) (io.ReadCloser, error) {
|
||||
if length < 0 || length > int64(int(^uint(0)>>1)) {
|
||||
return nil, fmt.Errorf("artifact range is invalid")
|
||||
}
|
||||
payload, err := store.ReadPayloadRange(artifactID, offset, int(length))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
}
|
||||
|
||||
func (store *MemoryArtifactBodyStore) CommitTransferPayload(session domain.ArtifactTransferSession) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -205,6 +227,12 @@ func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte
|
||||
return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600)
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) PutPayloadFromFile(artifactID string, sourcePath string) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
return copyAtomicFile(store.payloadPath(artifactID), sourcePath, 0o600)
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -243,6 +271,40 @@ func (store *FileArtifactBodyStore) ReadPayloadRange(artifactID string, offset i
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) OpenPayloadRange(artifactID string, offset int64, length int64) (io.ReadCloser, error) {
|
||||
if offset < 0 || length < 0 {
|
||||
return nil, fmt.Errorf("artifact range is invalid")
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
file, err := os.Open(store.payloadPath(artifactID))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, repo.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open artifact payload: %w", err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("stat artifact payload: %w", err)
|
||||
}
|
||||
if offset > info.Size() || length > info.Size()-offset {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("artifact range is invalid")
|
||||
}
|
||||
return sectionReadCloser{SectionReader: io.NewSectionReader(file, offset, length), file: file}, nil
|
||||
}
|
||||
|
||||
type sectionReadCloser struct {
|
||||
*io.SectionReader
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (reader sectionReadCloser) Close() error {
|
||||
return reader.file.Close()
|
||||
}
|
||||
|
||||
func (store *FileArtifactBodyStore) CommitTransferPayload(session domain.ArtifactTransferSession) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -332,6 +394,67 @@ func stableStorageKey(value string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func fileSizeAndChecksum(path string) (int64, string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, "", fmt.Errorf("artifact source is not a regular file")
|
||||
}
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return size, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func copyAtomicFile(destinationPath string, sourcePath string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o700); err != nil {
|
||||
return fmt.Errorf("create durable body directory: %w", err)
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open durable body source file: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
tmp := destinationPath + ".tmp"
|
||||
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open durable body temporary file: %w", err)
|
||||
}
|
||||
if err := file.Chmod(mode); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("chmod durable body temporary file: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(file, source); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("copy durable body: %w", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("sync durable body: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close durable body: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, destinationPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("replace durable body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomicFile(path string, payload []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create durable body directory: %w", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -16,6 +17,11 @@ import (
|
||||
const artifactDownloadStorageBehavior = "platform-durable-artifact-store"
|
||||
const artifactPayloadCacheLimit = 4 * 1024 * 1024
|
||||
|
||||
type ArtifactContentStream struct {
|
||||
Content domain.ArtifactContent
|
||||
Body io.ReadCloser
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) {
|
||||
artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID))
|
||||
if err != nil {
|
||||
@@ -74,18 +80,11 @@ func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request
|
||||
if artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.ArtifactContent{}, validationError("artifact must be available before download")
|
||||
}
|
||||
limit := request.Limit
|
||||
if limit == 0 {
|
||||
limit = validator.MaxArtifactDownloadBytes
|
||||
offset, length, partial, err := artifactContentBounds(artifact, request)
|
||||
if err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
if request.Offset > artifact.SizeBytes {
|
||||
return domain.ArtifactContent{}, validationError("artifact range exceeds metadata")
|
||||
}
|
||||
remaining := artifact.SizeBytes - request.Offset
|
||||
if int64(limit) > remaining {
|
||||
limit = int(remaining)
|
||||
}
|
||||
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, limit)
|
||||
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, offset, int(length))
|
||||
if err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
@@ -95,12 +94,12 @@ func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request
|
||||
ArtifactID: artifact.ID,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
Offset: request.Offset,
|
||||
Offset: offset,
|
||||
SizeBytes: int64(len(part)),
|
||||
TotalSizeBytes: artifact.SizeBytes,
|
||||
Checksum: artifact.Checksum,
|
||||
ContentChecksum: validator.BytesChecksum(part),
|
||||
Partial: request.Offset != 0 || int64(len(part)) != artifact.SizeBytes,
|
||||
Partial: partial,
|
||||
RangeSupported: true,
|
||||
Payload: part,
|
||||
StorageBehavior: artifactDownloadStorageBehavior,
|
||||
@@ -112,6 +111,66 @@ func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request
|
||||
return domain.CopyArtifactContent(content), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) OpenArtifactContentStreamForSession(sessionID string, request domain.ArtifactContentRequest) (ArtifactContentStream, error) {
|
||||
request = domain.CopyArtifactContentRequest(request)
|
||||
if err := validator.ValidateArtifactContentRequest(request); err != nil {
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
|
||||
if err != nil {
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateAvailable {
|
||||
return ArtifactContentStream{}, validationError("artifact must be available before download")
|
||||
}
|
||||
offset, length, partial, err := artifactContentBounds(artifact, request)
|
||||
if err != nil {
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
contentChecksum := artifact.Checksum
|
||||
if partial {
|
||||
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, offset, int(length))
|
||||
if err != nil {
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
contentChecksum = validator.BytesChecksum(payload)
|
||||
}
|
||||
reader, err := svc.artifactStore.OpenPayloadRange(artifact.ID, offset, length)
|
||||
if err != nil {
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
filename, contentType := svc.artifactDownloadPresentation(artifact)
|
||||
content := domain.ArtifactContent{ArtifactID: artifact.ID, Filename: filename, ContentType: contentType, Offset: offset, SizeBytes: length, TotalSizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, ContentChecksum: contentChecksum, Partial: partial, RangeSupported: true, StorageBehavior: artifactDownloadStorageBehavior, ServedAt: svc.now()}
|
||||
if err := validator.ValidateArtifactContent(content); err != nil {
|
||||
_ = reader.Close()
|
||||
return ArtifactContentStream{}, err
|
||||
}
|
||||
return ArtifactContentStream{Content: domain.CopyArtifactContent(content), Body: reader}, nil
|
||||
}
|
||||
|
||||
func artifactContentBounds(artifact domain.Artifact, request domain.ArtifactContentRequest) (int64, int64, bool, error) {
|
||||
if request.Offset > artifact.SizeBytes {
|
||||
return 0, 0, false, validationError("artifact range exceeds metadata")
|
||||
}
|
||||
length := int64(request.Limit)
|
||||
if request.Limit == 0 {
|
||||
if request.Offset == 0 {
|
||||
length = artifact.SizeBytes
|
||||
} else {
|
||||
length = int64(validator.MaxArtifactDownloadBytes)
|
||||
}
|
||||
}
|
||||
remaining := artifact.SizeBytes - request.Offset
|
||||
if length > remaining {
|
||||
length = remaining
|
||||
}
|
||||
if length <= 0 {
|
||||
return 0, 0, false, validationError("artifact range is empty")
|
||||
}
|
||||
partial := request.Offset != 0 || length != artifact.SizeBytes
|
||||
return request.Offset, length, partial, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeArtifactAccess(sessionID string, artifact domain.Artifact) error {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
@@ -223,15 +282,6 @@ func (svc *CoreService) distributionArtifactDownloadPresentation(artifactID stri
|
||||
}
|
||||
}
|
||||
}
|
||||
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
|
||||
if err == nil {
|
||||
for _, distribution := range clients {
|
||||
if distribution.ArtifactID == artifactID {
|
||||
format := packageFormatForTarget(distribution.TargetOS)
|
||||
return distributionPackageFilename(distribution.ProfileKey, distribution.TargetOS, distribution.TargetArch, format), packageContentType(format), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleInputIncludesGeneratedCompanionConfig(t *testing.T) {
|
||||
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get plugin: %v", err)
|
||||
}
|
||||
capabilities := []string{"component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"}
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
manager := &plugin.RuntimeProfiles.ClientManagers[index]
|
||||
if manager.Key != "scum-client-manager" {
|
||||
continue
|
||||
}
|
||||
manager.ConfigTemplates = []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}}
|
||||
manager.Health.IntervalSeconds = 30
|
||||
manager.Health.RequiredCapabilities = domain.CopyStringSlice(capabilities)
|
||||
}
|
||||
plugin.GameClientBridge.Companion = domain.GameClientBridgeCompanionDeclaration{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update companion declaration: %v", err)
|
||||
}
|
||||
|
||||
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "companion-config-build-v1")
|
||||
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "companion-config-deploy-v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue companion deployment: %v", err)
|
||||
}
|
||||
runSession := registerClientManagerRun(t, svc)
|
||||
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||
request := domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
|
||||
input, err := svc.GetClientManagerLifecycleInput(request)
|
||||
if err != nil {
|
||||
t.Fatalf("get companion lifecycle input: %v", err)
|
||||
}
|
||||
config := input.CompanionConfig
|
||||
if config == nil {
|
||||
t.Fatal("expected generated companion config input")
|
||||
}
|
||||
if config.SchemaVersion != 1 || config.ConfigTemplateRef != "config.yaml.example" || config.ConfigOutputRef != "config.yaml" || config.ConfigSchemaRef != "schemas/companion/config.schema.json" {
|
||||
t.Fatalf("unexpected companion template contract: %+v", config)
|
||||
}
|
||||
if config.InstallationID != view.Installation.ID || config.ServerInstanceID != instance.ID || config.PluginID != plugin.ID || config.ProfileKey != "scum-client-manager" || config.ArtifactID != distribution.ArtifactID || config.KeyGeneration != distribution.KeyGeneration || config.DeploymentGeneration != view.Installation.DeploymentGeneration {
|
||||
t.Fatalf("unexpected companion identity fence: %+v", config)
|
||||
}
|
||||
if strings.Join(config.Capabilities, ",") != strings.Join(capabilities, ",") || config.PlatformBaseURLSource != "run-control" || config.RegistrationProof != "hmac-sha256" || config.ProofMaterialEnv != "SCUM_COMPONENT_PROOF" || config.SessionMode != "component-session" || config.TLSPolicy != "verify-system-roots" {
|
||||
t.Fatalf("unexpected companion registration policy: %+v", config)
|
||||
}
|
||||
if config.HeartbeatIntervalSeconds != 30 || config.CommandPollIntervalSeconds != 5 || config.RequestTimeoutSeconds != 15 {
|
||||
t.Fatalf("unexpected companion timing policy: %+v", config)
|
||||
}
|
||||
|
||||
plugin, err = svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload plugin: %v", err)
|
||||
}
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "skip-verification"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist unsafe companion policy: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "security policy") {
|
||||
t.Fatalf("expected unsafe persisted policy to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.TLSPolicy = "verify-system-roots"
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("persist reserved proof environment: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "proofMaterialEnv") {
|
||||
t.Fatalf("expected reserved proof environment to fail closed, got %v", err)
|
||||
}
|
||||
|
||||
plugin.GameClientBridge.Companion.ProofMaterialEnv = "SCUM_COMPONENT_PROOF"
|
||||
for index := range plugin.RuntimeProfiles.ClientManagers {
|
||||
plugin.RuntimeProfiles.ClientManagers[index].ConfigTemplates = nil
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("remove config template: %v", err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(request); err == nil || !strings.Contains(err.Error(), "config template") {
|
||||
t.Fatalf("expected missing template to fail safely, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientManagerCompanionConfigInputIsOptionalForOtherProfiles(t *testing.T) {
|
||||
config, err := clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ProfileKey: "bridge-client"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err != nil || config != nil {
|
||||
t.Fatalf("expected no companion config for another profile, config=%+v err=%v", config, err)
|
||||
}
|
||||
|
||||
config, err = clientManagerCompanionConfigInput(
|
||||
domain.GamePlugin{ID: "game.example", GameClientBridge: domain.GameClientBridgeManifest{Companion: domain.GameClientBridgeCompanionDeclaration{ConfigFormat: "yaml"}}},
|
||||
domain.RuntimeClientManagerProfile{Key: "metrics-client"},
|
||||
domain.ClientManagerInstallation{ProfileKey: "metrics-client"},
|
||||
"artifact-1",
|
||||
"1.0.0",
|
||||
"revision-1",
|
||||
)
|
||||
if err == nil || config != nil || !strings.Contains(err.Error(), "incomplete") {
|
||||
t.Fatalf("expected partial companion declaration to fail closed, config=%+v err=%v", config, err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestClientManagerLifecycleBuildDeployRegisterHealthUpdateRollbackAndUninstall(t *testing.T) {
|
||||
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||
baseTime := svc.now()
|
||||
svc.now = func() time.Time { return baseTime }
|
||||
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-build-v1")
|
||||
view, err := svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleAvailable {
|
||||
t.Fatalf("expected available build projection, view=%+v err=%v", view, err)
|
||||
}
|
||||
|
||||
view, err = svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"})
|
||||
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleDeploying || view.Job.State != domain.JobStateQueued {
|
||||
t.Fatalf("queue deployment: view=%+v err=%v", view, err)
|
||||
}
|
||||
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"}); err != nil {
|
||||
t.Fatalf("idempotent deployment: %v", err)
|
||||
}
|
||||
runSession := registerClientManagerRun(t, svc)
|
||||
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||
input, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
|
||||
if err != nil || input.ArtifactID != distribution.ArtifactID || input.KeyGeneration != distribution.KeyGeneration || input.DeploymentGeneration != view.Installation.DeploymentGeneration || strings.Contains(strings.Join(input.Arguments, " "), "/Users/") {
|
||||
t.Fatalf("get fenced deployment input: input=%+v err=%v", input, err)
|
||||
}
|
||||
chunk, err := svc.ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 7})
|
||||
if err != nil || len(chunk.Payload) == 0 || chunk.ArtifactID != distribution.ArtifactID {
|
||||
t.Fatalf("read deployment chunk: chunk=%+v err=%v", chunk, err)
|
||||
}
|
||||
if _, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1}); err == nil {
|
||||
t.Fatal("expected stale attempt to be rejected")
|
||||
}
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.Status != domain.ClientManagerLifecycleRegistering || view.Installation.ActiveArtifactID != distribution.ArtifactID {
|
||||
t.Fatalf("expected deployed registration state, got %+v", view.Installation)
|
||||
}
|
||||
|
||||
componentKey := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
|
||||
registerRequest := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, baseTime)
|
||||
registerRequest.Signature = clientManagerRegistrationSignature(componentKey, registerRequest)
|
||||
registration, err := svc.RegisterClientManager(registerRequest)
|
||||
if err != nil || !registration.Accepted || registration.SessionToken == "" {
|
||||
t.Fatalf("register client manager: result=%+v err=%v", registration, err)
|
||||
}
|
||||
if _, err := svc.RegisterClientManager(registerRequest); err == nil {
|
||||
t.Fatal("expected registration nonce replay rejection")
|
||||
}
|
||||
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: runSession, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
|
||||
t.Fatal("Run control session must not authenticate as a Client Manager session")
|
||||
}
|
||||
heartbeat, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", Capabilities: registerRequest.Capabilities, SentAt: baseTime})
|
||||
if err != nil || heartbeat.Status != domain.ClientManagerLifecycleOnline {
|
||||
t.Fatalf("accept heartbeat: result=%+v err=%v", heartbeat, err)
|
||||
}
|
||||
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
|
||||
t.Fatal("expected replayed heartbeat sequence rejection")
|
||||
}
|
||||
|
||||
baseTime = baseTime.Add(50 * time.Second)
|
||||
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
|
||||
t.Fatalf("reconcile degraded health: %v", err)
|
||||
}
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.Status != domain.ClientManagerLifecycleDegraded {
|
||||
t.Fatalf("expected degraded heartbeat timeout, got %+v", view.Installation)
|
||||
}
|
||||
baseTime = baseTime.Add(80 * time.Second)
|
||||
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
|
||||
t.Fatalf("reconcile offline health: %v", err)
|
||||
}
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.Status != domain.ClientManagerLifecycleOffline {
|
||||
t.Fatalf("expected offline heartbeat timeout, got %+v", view.Installation)
|
||||
}
|
||||
|
||||
updatedDistribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.1.0", "lifecycle-build-v2")
|
||||
view, err = svc.UpdateClientManagerForSession(ownerSession, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: updatedDistribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Approved: true, IdempotencyKey: "lifecycle-update-v2"})
|
||||
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleUpdating {
|
||||
t.Fatalf("queue staged update: view=%+v err=%v", view, err)
|
||||
}
|
||||
runSession = registerClientManagerRun(t, svc)
|
||||
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateFailed, "client-manager.rollback.restored", "running")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.ActiveArtifactID != distribution.ArtifactID || !strings.Contains(view.Installation.Phase, "previous deployment restored") || !view.Installation.Retryable {
|
||||
t.Fatalf("expected failed update to retain previous active slot, got %+v", view.Installation)
|
||||
}
|
||||
view, err = svc.RetryClientManagerLifecycleForSession(ownerSession, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-update-v2-retry"})
|
||||
if err != nil {
|
||||
t.Fatalf("retry staged update: %v", err)
|
||||
}
|
||||
runSession = registerClientManagerRun(t, svc)
|
||||
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.updated", "running")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.ActiveArtifactID != updatedDistribution.ArtifactID || view.Installation.PreviousArtifactID != distribution.ArtifactID || view.Installation.Status != domain.ClientManagerLifecycleRegistering {
|
||||
t.Fatalf("expected successful update slot commit, got %+v", view.Installation)
|
||||
}
|
||||
|
||||
view, err = svc.ControlClientManagerForSession(ownerSession, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", Operation: domain.ClientManagerOperationRollback, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-rollback-v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue explicit rollback: %v", err)
|
||||
}
|
||||
runSession = registerClientManagerRun(t, svc)
|
||||
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerRollback)
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.rolled-back", "running")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.ActiveArtifactID != distribution.ArtifactID || view.Installation.PreviousArtifactID != updatedDistribution.ArtifactID {
|
||||
t.Fatalf("expected rollback slot swap, got %+v", view.Installation)
|
||||
}
|
||||
|
||||
view, err = svc.UninstallClientManagerForSession(ownerSession, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Confirmed: true, IdempotencyKey: "lifecycle-uninstall"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue uninstall: %v", err)
|
||||
}
|
||||
runSession = registerClientManagerRun(t, svc)
|
||||
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUninstall)
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.uninstalled", "stopped")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if view.Installation.Status != domain.ClientManagerLifecycleUninstalled || view.Installation.ActiveArtifactID != "" {
|
||||
t.Fatalf("expected durable uninstalled history, got %+v", view.Installation)
|
||||
}
|
||||
if _, err := svc.store.ClientManagerDistributions().Get(distribution.ID); err != nil {
|
||||
t.Fatalf("uninstall must retain distribution history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientManagerLifecycleRejectsCrossScopeStaleAndRevokedIdentity(t *testing.T) {
|
||||
svc, ownerSession, instance := newDistributionTestFixture(t)
|
||||
now := svc.now()
|
||||
svc.now = func() time.Time { return now }
|
||||
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-scope-build")
|
||||
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-scope-deploy"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue deploy: %v", err)
|
||||
}
|
||||
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "other-owner", DisplayName: "Other", Email: "other-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||
if _, err := svc.GetClientManagerLifecycleForSession(otherSession, instance.ID, "scum-client-manager"); err == nil {
|
||||
t.Fatal("expected cross-owner lifecycle read denial")
|
||||
}
|
||||
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration + 1, IdempotencyKey: "lifecycle-stale-deploy"}); err == nil {
|
||||
t.Fatal("expected stale deployment generation denial")
|
||||
}
|
||||
runSession := registerClientManagerRun(t, svc)
|
||||
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
|
||||
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
plain := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
|
||||
request := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
|
||||
request.ArtifactID = "cross-server-artifact"
|
||||
request.Signature = clientManagerRegistrationSignature(plain, request)
|
||||
if _, err := svc.RegisterClientManager(request); err == nil {
|
||||
t.Fatal("expected cross-artifact registration rejection")
|
||||
}
|
||||
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
|
||||
request.KeyGeneration++
|
||||
request.Nonce = "nonce-stale-key-generation"
|
||||
request.Signature = clientManagerRegistrationSignature(plain, request)
|
||||
if _, err := svc.RegisterClientManager(request); err == nil {
|
||||
t.Fatal("expected stale key generation registration rejection")
|
||||
}
|
||||
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
|
||||
request.Nonce = "nonce-valid-component-identity"
|
||||
request.Signature = clientManagerRegistrationSignature(plain, request)
|
||||
registration, err := svc.RegisterClientManager(request)
|
||||
if err != nil {
|
||||
t.Fatalf("register valid component: %v", err)
|
||||
}
|
||||
now = now.Add(16 * time.Minute)
|
||||
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
|
||||
t.Fatalf("expire component session: %v", err)
|
||||
}
|
||||
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
|
||||
t.Fatal("expected expired component session rejection")
|
||||
}
|
||||
request.Timestamp = now
|
||||
request.Nonce = "nonce-replacement-after-expiry"
|
||||
request.Signature = clientManagerRegistrationSignature(plain, request)
|
||||
registration, err = svc.RegisterClientManager(request)
|
||||
if err != nil {
|
||||
t.Fatalf("register replacement component session: %v", err)
|
||||
}
|
||||
if _, err := svc.ResetComponentKeyForSession(ownerSession, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: "scum-client-manager"}); err != nil {
|
||||
t.Fatalf("reset component key: %v", err)
|
||||
}
|
||||
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
|
||||
t.Fatal("expected reset to revoke component session")
|
||||
}
|
||||
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
|
||||
if !view.Installation.RequiresRedeploy || view.Installation.Status != domain.ClientManagerLifecycleFailed {
|
||||
t.Fatalf("expected key reset recovery projection, got %+v", view.Installation)
|
||||
}
|
||||
for _, forbidden := range []string{plain, registration.SessionToken, "secret://", "/Users/", "tcp://"} {
|
||||
payload := strings.Join([]string{view.Installation.Phase, view.Installation.HealthReason}, " ")
|
||||
if strings.Contains(payload, forbidden) {
|
||||
t.Fatalf("safe lifecycle view leaked %q: %s", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string, instance domain.ServerInstance, version, idempotency string) domain.ClientManagerDistribution {
|
||||
t.Helper()
|
||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
t.Fatalf("get lifecycle Run endpoint: %v", err)
|
||||
}
|
||||
endpoint.LastHeartbeatAt = svc.now()
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("refresh lifecycle Run heartbeat: %v", err)
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("get lifecycle plugin: %v", err)
|
||||
}
|
||||
for i := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if plugin.RuntimeProfiles.ClientManagers[i].Key == "scum-client-manager" {
|
||||
plugin.RuntimeProfiles.ClientManagers[i].Version = version
|
||||
}
|
||||
}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update lifecycle version: %v", err)
|
||||
}
|
||||
payload := []byte("client-manager-package-" + version)
|
||||
svc.ConfigureDistributionBuilder(staticDistributionBuilder{payload: payload})
|
||||
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
|
||||
if err != nil {
|
||||
t.Fatalf("generate lifecycle distribution: %v", err)
|
||||
}
|
||||
return completeClientDistributionBuild(t, svc, distribution, payload)
|
||||
}
|
||||
|
||||
func registerClientManagerRun(t *testing.T, svc *CoreService) string {
|
||||
t.Helper()
|
||||
hello := validRunControlHello()
|
||||
hello.Platform = "linux"
|
||||
hello.Architecture = "amd64"
|
||||
hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall)
|
||||
result, err := svc.RegisterRunHello(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("register lifecycle Run: %v", err)
|
||||
}
|
||||
return result.SessionToken
|
||||
}
|
||||
|
||||
func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capability string) domain.RunJobClaimResult {
|
||||
t.Helper()
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.Capability != capability {
|
||||
t.Fatalf("claim %s job: claim=%+v err=%v", capability, claim, err)
|
||||
}
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "typed lifecycle work started"}); err != nil {
|
||||
t.Fatalf("ack lifecycle job: %v", err)
|
||||
}
|
||||
return claim
|
||||
}
|
||||
|
||||
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
|
||||
t.Helper()
|
||||
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, Summary: "bounded lifecycle result"}})
|
||||
if err != nil {
|
||||
t.Fatalf("complete lifecycle job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func currentClientManagerPlainKey(t *testing.T, svc *CoreService, serverID, profileKey string) string {
|
||||
t.Helper()
|
||||
key, err := svc.activeComponentKey(serverID, domain.DistributionComponentClientManager, profileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get active component key: %v", err)
|
||||
}
|
||||
plain, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt component key: %v", err)
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
func lifecycleRegisterRequest(installation domain.ClientManagerInstallation, capabilities []string, stamp time.Time) domain.ClientManagerRegisterRequest {
|
||||
return domain.ClientManagerRegisterRequest{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, ArtifactID: installation.ActiveArtifactID, Version: installation.ActiveVersion, SourceRevision: installation.ActiveRevision, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, Capabilities: capabilities, Timestamp: stamp, Nonce: "nonce-client-manager-registration"}
|
||||
}
|
||||
@@ -389,11 +389,6 @@ func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testi
|
||||
domain.JobCapabilityFilesRead,
|
||||
"files.patch",
|
||||
"logs.read",
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
"artifacts.read",
|
||||
"artifacts.write",
|
||||
"ai.invoke",
|
||||
|
||||
@@ -172,7 +172,7 @@ func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExe
|
||||
if err != nil {
|
||||
return domain.DependencyExecutionInput{}, err
|
||||
}
|
||||
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, Probe: probe, Plan: plan, Bindings: bindings}), nil
|
||||
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, ServerRoot: strings.TrimSpace(resolution.instance.Deployment.ServerRoot), Probe: probe, Plan: plan, Bindings: bindings}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) {
|
||||
@@ -208,13 +208,6 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest)
|
||||
if err != nil {
|
||||
return domain.RunUpdateChunk{}, err
|
||||
}
|
||||
payload, err := svc.artifactPayload(artifact.ID)
|
||||
if err != nil {
|
||||
return domain.RunUpdateChunk{}, err
|
||||
}
|
||||
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
||||
return domain.RunUpdateChunk{}, validationError("update artifact content does not match metadata")
|
||||
}
|
||||
if request.Offset >= artifact.SizeBytes {
|
||||
return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact")
|
||||
}
|
||||
@@ -224,7 +217,11 @@ func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest)
|
||||
length = int(remaining)
|
||||
}
|
||||
end := request.Offset + int64(length)
|
||||
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil
|
||||
payload, err := svc.artifactStore.ReadPayloadRange(artifact.ID, request.Offset, length)
|
||||
if err != nil {
|
||||
return domain.RunUpdateChunk{}, err
|
||||
}
|
||||
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload, Complete: end == artifact.SizeBytes}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) {
|
||||
|
||||
@@ -231,6 +231,10 @@ func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
|
||||
if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) {
|
||||
t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err)
|
||||
}
|
||||
secondChunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 8, Length: 5})
|
||||
if err != nil || string(secondChunk.Payload) != string(payload[8:13]) || secondChunk.Offset != 8 || secondChunk.Complete {
|
||||
t.Fatalf("read offset update chunk: chunk=%+v err=%v", secondChunk, err)
|
||||
}
|
||||
if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil {
|
||||
t.Fatal("expected stale update chunk attempt rejection")
|
||||
}
|
||||
@@ -300,3 +304,31 @@ func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHell
|
||||
Capacity: domain.RunCapacity{MaxJobs: 2},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyInputCarriesDeclaredServerInstallRoot(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
instance.Deployment.ServerRoot = "C:/scumserver"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("set deployment server root: %v", err)
|
||||
}
|
||||
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("dependency catalog: %v", err)
|
||||
}
|
||||
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-install-root"})
|
||||
if err != nil {
|
||||
t.Fatalf("queue dependency check: %v", err)
|
||||
}
|
||||
runSession := registerDependencyUpdateRun(t, svc, instance)
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
|
||||
t.Fatalf("claim dependency check: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
input, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
|
||||
if err != nil {
|
||||
t.Fatalf("dependency input: %v", err)
|
||||
}
|
||||
if input.ServerRoot != "C:/scumserver" {
|
||||
t.Fatalf("expected dependency input to carry the declared server install root, got %q", input.ServerRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,22 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
|
||||
return nil
|
||||
}
|
||||
builder := svc.configuredDistributionBuilder()
|
||||
if fileBuilder, ok := builder.(distributionBuilderWithFileOutput); ok {
|
||||
output, buildErr := fileBuilder.BuildFile(input, func(progress DistributionBuildProgress) {
|
||||
_ = svc.updateDistributionBuildProgress(job, progress)
|
||||
})
|
||||
if buildErr != nil {
|
||||
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey))
|
||||
}
|
||||
defer func() { _ = output.Cleanup() }()
|
||||
if _, err := svc.platformDistributionBuildInput(job); err != nil {
|
||||
return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current")
|
||||
}
|
||||
if err := svc.storeDistributionBuildArtifactFile(input.ArtifactID, job.ID, output); err != nil {
|
||||
return svc.failDistributionBuildJob(job, "platform builder could not record the distribution artifact")
|
||||
}
|
||||
return svc.succeedDistributionBuildJob(job, input.ArtifactID)
|
||||
}
|
||||
var payload []byte
|
||||
var buildErr error
|
||||
if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok {
|
||||
@@ -124,6 +140,10 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
|
||||
// component auth key, inside the platform. Unlike GetDistributionBuildInput it
|
||||
// never crosses the job channel.
|
||||
func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.DistributionBuildInput, error) {
|
||||
platformURL, err := runReleasePlatformURL()
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
@@ -157,7 +177,7 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PlatformURL: platformURL,
|
||||
PackageFormat: distribution.PackageFormat,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: executableFilename("run", distribution.TargetOS),
|
||||
@@ -169,45 +189,6 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D
|
||||
}, nil
|
||||
}
|
||||
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if key.Generation != distribution.KeyGeneration {
|
||||
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
|
||||
}
|
||||
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -234,6 +215,7 @@ func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDis
|
||||
} else if !errors.Is(bindingErr, repo.ErrNotFound) {
|
||||
return runDistributionPackageContext{}, bindingErr
|
||||
}
|
||||
profileKey = defaultRunDistributionProfileKey(plugin, profileKey)
|
||||
plan, err := runAutonomousLifecyclePlan(distribution, instance, plugin, profileKey, bindings)
|
||||
if err != nil {
|
||||
return runDistributionPackageContext{}, err
|
||||
@@ -246,6 +228,7 @@ func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDis
|
||||
}
|
||||
|
||||
func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) {
|
||||
privateBindings := autonomousDataTargetBindings(bindings, instance, plugin.RuntimeProfiles.DataTargets)
|
||||
plan := &domain.RunAutonomousLifecyclePlan{
|
||||
SchemaVersion: "1",
|
||||
ServerInstanceID: instance.ID,
|
||||
@@ -257,8 +240,8 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
DeploymentRevision: instance.Deployment.Revision,
|
||||
RuntimeBindings: domain.CopyStringMap(bindings),
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, bindings),
|
||||
RuntimeBindings: privateBindings,
|
||||
Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, privateBindings),
|
||||
}
|
||||
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey)
|
||||
for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} {
|
||||
@@ -287,6 +270,11 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
plan.LogSources = append(plan.LogSources, autonomousLogSource(source))
|
||||
}
|
||||
}
|
||||
for _, target := range plugin.RuntimeProfiles.DataTargets {
|
||||
if runtimePlatformsContain(target.Platforms, distribution.TargetOS) {
|
||||
plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target))
|
||||
}
|
||||
}
|
||||
if hasProfile && len(profile.DLLExtensionRefs) > 0 {
|
||||
endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch}
|
||||
extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
|
||||
@@ -300,6 +288,19 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do
|
||||
return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil
|
||||
}
|
||||
|
||||
func autonomousDataTargetBindings(bindings map[string]string, instance domain.ServerInstance, targets []domain.RuntimeDataTarget) map[string]string {
|
||||
result := domain.CopyStringMap(bindings)
|
||||
if result == nil {
|
||||
result = map[string]string{}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if result[target.SourceRootKey] == "" && target.SourceRootKey == "server-root" && instance.Deployment.ServerRoot != "" {
|
||||
result[target.SourceRootKey] = instance.Deployment.ServerRoot
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction {
|
||||
targetKey := ""
|
||||
if hasProfile {
|
||||
@@ -322,7 +323,7 @@ func autonomousBootstrapLifecycleAction(plugin domain.GamePlugin, profile domain
|
||||
}
|
||||
|
||||
func autonomousDependencyProbe(probe domain.RuntimeDependencyProbe) domain.RunAutonomousDependencyProbe {
|
||||
return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, Platforms: domain.CopyStringSlice(probe.Platforms)}
|
||||
return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, SteamAppID: probe.SteamAppID, Platforms: domain.CopyStringSlice(probe.Platforms)}
|
||||
}
|
||||
|
||||
func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan {
|
||||
@@ -342,7 +343,11 @@ func autonomousRunLogSourceKind(kind string) bool {
|
||||
}
|
||||
|
||||
func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension {
|
||||
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
|
||||
return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, TargetExecutableChecksum: extension.TargetExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}
|
||||
}
|
||||
|
||||
func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget {
|
||||
return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)}
|
||||
}
|
||||
|
||||
func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment {
|
||||
@@ -437,6 +442,49 @@ func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) storeDistributionBuildArtifactFile(artifactID string, jobID string, output DistributionBuildOutput) error {
|
||||
if strings.TrimSpace(artifactID) == "" {
|
||||
return validationError("distribution build artifact id is required")
|
||||
}
|
||||
if strings.TrimSpace(output.Path) == "" || output.SizeBytes <= 0 || output.Checksum == "" {
|
||||
return validationError("distribution build produced no package file")
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
artifact, err := svc.store.Artifacts().Get(artifactID)
|
||||
if err != nil && !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
create := errors.Is(err, repo.ErrNotFound)
|
||||
if create {
|
||||
artifact = domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: jobID, CreatedAt: stamp}
|
||||
}
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != jobID {
|
||||
return validationError("distribution build artifact is outside the job scope")
|
||||
}
|
||||
artifact.SizeBytes = output.SizeBytes
|
||||
artifact.Checksum = output.Checksum
|
||||
artifact.State = domain.ArtifactStateAvailable
|
||||
artifact.UpdatedAt = stamp
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.artifactStore.PutPayloadFromFile(artifact.ID, output.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if create {
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := svc.store.Artifacts().Update(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(svc.artifactPayloads, artifact.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error {
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
type captureDistributionBuilder struct {
|
||||
@@ -25,32 +29,9 @@ type progressDistributionBuilder struct {
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type failingBuildProgressStore struct {
|
||||
repo.Store
|
||||
buildJobs repo.ClientManagerBuildJobRepository
|
||||
}
|
||||
|
||||
func (store failingBuildProgressStore) ClientManagerBuildJobs() repo.ClientManagerBuildJobRepository {
|
||||
return store.buildJobs
|
||||
}
|
||||
|
||||
type failWhenDistributionJobRunningRepository struct {
|
||||
repo.ClientManagerBuildJobRepository
|
||||
jobs repo.JobRepository
|
||||
err error
|
||||
}
|
||||
|
||||
func (repository failWhenDistributionJobRunningRepository) List(filter domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) {
|
||||
jobs, err := repository.jobs.List(domain.JobFilter{ServerInstanceID: filter.ServerInstanceID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if job.Capability == domain.JobCapabilityDistributionBuild && job.State == domain.JobStateRunning {
|
||||
return nil, repository.err
|
||||
}
|
||||
}
|
||||
return repository.ClientManagerBuildJobRepository.List(filter)
|
||||
type fileDistributionBuilder struct {
|
||||
inputs chan domain.DistributionBuildInput
|
||||
outputPath string
|
||||
}
|
||||
|
||||
func (builder captureDistributionBuilder) Readiness() (bool, string) {
|
||||
@@ -86,6 +67,28 @@ func (builder progressDistributionBuilder) BuildWithProgress(_ domain.Distributi
|
||||
return domain.CopyBytes(builder.payload), nil
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) Readiness() (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) {
|
||||
return nil, errors.New("byte build path should not be used for file output builders")
|
||||
}
|
||||
|
||||
func (builder fileDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) {
|
||||
if progress != nil {
|
||||
progress(DistributionBuildProgress{Percent: 88, Message: "package_finalize: output file ready"})
|
||||
}
|
||||
if builder.inputs != nil {
|
||||
builder.inputs <- input
|
||||
}
|
||||
sizeBytes, checksum, err := fileSizeAndChecksum(builder.outputPath)
|
||||
if err != nil {
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
return DistributionBuildOutput{Path: builder.outputPath, SizeBytes: sizeBytes, Checksum: checksum}, nil
|
||||
}
|
||||
|
||||
func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
@@ -99,11 +102,26 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
}
|
||||
plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources,
|
||||
domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "game.server", CursorKind: "fingerprint", RetentionDays: 90},
|
||||
)
|
||||
plugin.RuntimeProfiles.DataTargets = []domain.RuntimeDataTarget{{
|
||||
Key: "game-database",
|
||||
Kind: "sqlite.snapshot",
|
||||
TransportKey: "game-database",
|
||||
SourceRootKey: "server-root",
|
||||
SourcePath: "Game/Saved/SaveFiles/state.db",
|
||||
WorkspaceKey: "databases/game-database",
|
||||
RefreshPolicy: "on-demand-snapshot",
|
||||
MaxBytes: 1024 * 1024,
|
||||
Platforms: []string{"linux"},
|
||||
}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("seed plugin lifecycle assets: %v", err)
|
||||
}
|
||||
instance.Deployment.ServerRoot = "/srv/game"
|
||||
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||
t.Fatalf("seed instance deployment root: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
@@ -149,7 +167,10 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) {
|
||||
if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" {
|
||||
t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") {
|
||||
if len(plan.DataTargets) != 1 || plan.DataTargets[0].Key != "game-database" || plan.DataTargets[0].Kind != "sqlite.snapshot" || plan.DataTargets[0].SourcePath != "Game/Saved/SaveFiles/state.db" || plan.RuntimeBindings["server-root"] != instance.Deployment.ServerRoot {
|
||||
t.Fatalf("autonomous lifecycle plan lost private database target bindings: %+v", plan)
|
||||
}
|
||||
if hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || hasAutonomousLogSource(plan.LogSources, "file.tail", "game.server") {
|
||||
t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources)
|
||||
}
|
||||
var seededPlan domain.RunAutonomousLifecyclePlan
|
||||
@@ -403,47 +424,45 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
|
||||
completeDistributionBuild(t, svc, distribution, nil)
|
||||
}
|
||||
|
||||
func TestCoreServiceTerminalizesPlatformBuildWhenRunningProjectionFails(t *testing.T) {
|
||||
func TestCoreServiceStoresPlatformBuildFileOutput(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
const leakedDetail = "sensitive-builder-token /private/platform/build/input/auth-key"
|
||||
baseStore := svc.store
|
||||
svc.store = failingBuildProgressStore{
|
||||
Store: baseStore,
|
||||
buildJobs: failWhenDistributionJobRunningRepository{
|
||||
ClientManagerBuildJobRepository: baseStore.ClientManagerBuildJobs(),
|
||||
jobs: baseStore.Jobs(),
|
||||
err: errors.New(leakedDetail),
|
||||
},
|
||||
outputPath := filepath.Join(t.TempDir(), "run")
|
||||
payload := bytes.Repeat([]byte("platform-build-file-output"), 8192)
|
||||
if err := os.WriteFile(outputPath, payload, 0o600); err != nil {
|
||||
t.Fatalf("write builder file output: %v", err)
|
||||
}
|
||||
inputs := make(chan domain.DistributionBuildInput, 1)
|
||||
svc.ConfigureDistributionBuilder(fileDistributionBuilder{inputs: inputs, outputPath: outputPath})
|
||||
|
||||
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "running-projection-failure-terminalizes",
|
||||
IdempotencyKey: "platform-builder-file-output",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("queue platform distribution: %v", err)
|
||||
t.Fatalf("generate platform distribution: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
job, jobErr := svc.GetJob(distribution.BuildJobID)
|
||||
updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID)
|
||||
if jobErr != nil || distributionErr != nil {
|
||||
t.Fatalf("read failed build state: jobErr=%v distributionErr=%v", jobErr, distributionErr)
|
||||
}
|
||||
if isTerminalJobState(job.State) {
|
||||
if job.State != domain.JobStateFailed || updated.Status != domain.DistributionStatusFailed {
|
||||
t.Fatalf("projection failure did not end in failed state: job=%+v distribution=%+v", job, updated)
|
||||
}
|
||||
if strings.Contains(job.Progress.Message, "sensitive-builder-token") || strings.Contains(job.Progress.Message, "/private/") || strings.Contains(job.TerminalFingerprint, "sensitive-builder-token") {
|
||||
t.Fatalf("terminal build state leaked internal failure details: %+v", job)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
select {
|
||||
case <-inputs:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("platform file builder did not receive internal build input")
|
||||
}
|
||||
distribution = completeDistributionBuild(t, svc, distribution, nil)
|
||||
artifact, err := svc.GetArtifact(distribution.ArtifactID)
|
||||
if err != nil {
|
||||
t.Fatalf("get distribution artifact: %v", err)
|
||||
}
|
||||
if artifact.SizeBytes != int64(len(payload)) || artifact.Checksum != validator.BytesChecksum(payload) {
|
||||
t.Fatalf("artifact metadata did not come from file output: %+v", artifact)
|
||||
}
|
||||
stored, err := svc.artifactStore.GetPayload(distribution.ArtifactID)
|
||||
if err != nil {
|
||||
t.Fatalf("get stored distribution payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(stored, payload) {
|
||||
t.Fatalf("stored distribution payload changed during file-backed recording")
|
||||
}
|
||||
t.Fatal("platform build remained non-terminal after running projection failure")
|
||||
}
|
||||
|
||||
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
|
||||
@@ -614,7 +633,7 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
|
||||
}
|
||||
seen := 0
|
||||
for _, action := range actions.Actions {
|
||||
if action.Key != "generate-run" && action.Key != "generate-client-manager" {
|
||||
if action.Key != "generate-run" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
@@ -622,8 +641,8 @@ func TestCoreServiceReportsPlatformBuilderUnavailable(t *testing.T) {
|
||||
t.Fatalf("expected explicit platform builder unavailable reason, got %+v", action)
|
||||
}
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Fatalf("expected both build actions, got %+v", actions.Actions)
|
||||
if seen != 1 {
|
||||
t.Fatalf("expected run build action, got %+v", actions.Actions)
|
||||
}
|
||||
|
||||
_, err = svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +30,10 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
return domain.DistributionBuildInput{}, validationError("distribution build job is not active")
|
||||
}
|
||||
platformURL, err := runReleasePlatformURL()
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
|
||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
@@ -65,7 +68,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
TargetRelease: distribution.ID,
|
||||
PlatformURL: runReleasePlatformURL(),
|
||||
PlatformURL: platformURL,
|
||||
PackageFormat: distribution.PackageFormat,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: executableFilename("run", distribution.TargetOS),
|
||||
@@ -77,44 +80,6 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
|
||||
}, nil
|
||||
}
|
||||
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
if key.Generation != distribution.KeyGeneration {
|
||||
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
|
||||
}
|
||||
plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return domain.DistributionBuildInput{}, err
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
ProfileKey: distribution.ProfileKey,
|
||||
TargetOS: distribution.TargetOS,
|
||||
TargetArch: distribution.TargetArch,
|
||||
PackageFormat: packageFormatForTarget(distribution.TargetOS),
|
||||
RepositoryURL: distribution.RepositoryURL,
|
||||
SourceRevision: distribution.SourceRevision,
|
||||
ArtifactID: distribution.ArtifactID,
|
||||
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
|
||||
SecretRef: distribution.SecretRef,
|
||||
KeyGeneration: distribution.KeyGeneration,
|
||||
AuthKey: plainKey,
|
||||
}, nil
|
||||
}
|
||||
return domain.DistributionBuildInput{}, repo.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -122,21 +87,6 @@ func (svc *CoreService) projectDistributionBuildProgress(job domain.Job, stamp t
|
||||
if job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return nil
|
||||
}
|
||||
builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, build := range builds {
|
||||
if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued {
|
||||
continue
|
||||
}
|
||||
build.Status = domain.DistributionJobStatusRunning
|
||||
build.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.ClientManagerBuildJobs().Update(build)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -148,7 +98,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
|
||||
return err
|
||||
}
|
||||
status := domain.DistributionStatusFailed
|
||||
buildStatus := domain.DistributionJobStatusFailed
|
||||
var artifact domain.Artifact
|
||||
if job.State == domain.JobStateSucceeded {
|
||||
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
|
||||
@@ -164,7 +113,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
|
||||
return validationError("distribution build artifact is unavailable or outside the job scope")
|
||||
}
|
||||
status = domain.DistributionStatusAvailable
|
||||
buildStatus = domain.DistributionJobStatusSucceeded
|
||||
}
|
||||
|
||||
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
@@ -195,57 +143,6 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
|
||||
return svc.store.RunDistributions().Update(distribution)
|
||||
}
|
||||
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID != job.ID {
|
||||
continue
|
||||
}
|
||||
if distribution.Status == domain.DistributionStatusRevoked {
|
||||
if artifact.ID != "" {
|
||||
return svc.expireDistributionArtifact(artifact.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
|
||||
return validationError("client-manager build returned an unexpected artifact")
|
||||
}
|
||||
distribution.Status = status
|
||||
if artifact.ID != "" {
|
||||
distribution.Checksum = artifact.Checksum
|
||||
}
|
||||
distribution.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.ProjectClientManagerDistribution(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
|
||||
if err != nil && !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
build.Status = buildStatus
|
||||
if artifact.ID != "" {
|
||||
build.ArtifactID = artifact.ID
|
||||
build.Checksum = artifact.Checksum
|
||||
}
|
||||
build.UpdatedAt = stamp
|
||||
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -265,18 +162,6 @@ func (svc *CoreService) validateDistributionBuildResult(job domain.Job) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
if expectedArtifactID == "" {
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.BuildJobID == job.ID {
|
||||
expectedArtifactID = distribution.ArtifactID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if expectedArtifactID == "" {
|
||||
return repo.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -22,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
// DistributionBuilder executes a distribution build inside a platform-owned
|
||||
@@ -42,10 +41,28 @@ type DistributionBuildProgress struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type DistributionBuildOutput struct {
|
||||
Path string
|
||||
SizeBytes int64
|
||||
Checksum string
|
||||
cleanup func() error
|
||||
}
|
||||
|
||||
func (output DistributionBuildOutput) Cleanup() error {
|
||||
if output.cleanup == nil {
|
||||
return nil
|
||||
}
|
||||
return output.cleanup()
|
||||
}
|
||||
|
||||
type distributionBuilderWithProgress interface {
|
||||
BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error)
|
||||
}
|
||||
|
||||
type distributionBuilderWithFileOutput interface {
|
||||
BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error)
|
||||
}
|
||||
|
||||
// DockerDistributionBuilderConfig configures a container-per-build builder.
|
||||
type DockerDistributionBuilderConfig struct {
|
||||
DockerBinary string
|
||||
@@ -118,8 +135,7 @@ func runCommandStreamCombined(ctx context.Context, name string, args []string, o
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
mu.Lock()
|
||||
output.WriteString(line)
|
||||
output.WriteByte('\n')
|
||||
appendBoundedBuilderOutput(&output, []byte(line+"\n"))
|
||||
mu.Unlock()
|
||||
if onLine != nil {
|
||||
onLine(line)
|
||||
@@ -199,17 +215,33 @@ func (builder *DockerDistributionBuilder) Build(input domain.DistributionBuildIn
|
||||
}
|
||||
|
||||
func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) ([]byte, error) {
|
||||
output, err := builder.BuildFile(input, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = output.Cleanup() }()
|
||||
binary, err := os.ReadFile(output.Path)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder did not produce a readable distribution executable")
|
||||
}
|
||||
if int64(len(binary)) != output.SizeBytes || validator.BytesChecksum(binary) != output.Checksum {
|
||||
return nil, validationError("platform builder output changed before it could be read")
|
||||
}
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func (builder *DockerDistributionBuilder) BuildFile(input domain.DistributionBuildInput, progress func(DistributionBuildProgress)) (DistributionBuildOutput, error) {
|
||||
if ready, reason := builder.Readiness(); !ready {
|
||||
return nil, validationError(reason)
|
||||
return DistributionBuildOutput{}, validationError(reason)
|
||||
}
|
||||
reportBuilderProgress(progress, 8, "env_check: platform builder readiness verified")
|
||||
sourceDir, err := filepath.Abs(strings.TrimSpace(builder.config.SourceDir))
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder run source directory is invalid")
|
||||
return DistributionBuildOutput{}, validationError("platform builder run source directory is invalid")
|
||||
}
|
||||
workspaceDir, err := filepath.Abs(strings.TrimSpace(builder.config.WorkspaceDir))
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder workspace directory is invalid")
|
||||
return DistributionBuildOutput{}, validationError("platform builder workspace directory is invalid")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), builder.config.Timeout)
|
||||
defer cancel()
|
||||
@@ -222,14 +254,20 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
}
|
||||
cacheDir, err := builder.cacheDir(workspaceDir)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder cache directory is invalid")
|
||||
return DistributionBuildOutput{}, validationError("platform builder cache directory is invalid")
|
||||
}
|
||||
// Workspaces stay isolated per plugin and per job as required by
|
||||
// run-build-download-flow.
|
||||
jobDir := filepath.Join(workspaceDir, sanitizeIDPart(input.PluginID), sanitizeIDPart(input.JobID))
|
||||
if err := os.RemoveAll(jobDir); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
cleanupJob := true
|
||||
defer func() {
|
||||
if cleanupJob {
|
||||
_ = os.RemoveAll(jobDir)
|
||||
}
|
||||
}()
|
||||
outputDir := filepath.Join(jobDir, "output")
|
||||
inputDir := filepath.Join(jobDir, "input")
|
||||
buildDir := filepath.Join(jobDir, "build")
|
||||
@@ -238,15 +276,14 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
goModCacheDir := filepath.Join(cacheDir, "go-mod")
|
||||
for _, directory := range []string{outputDir, inputDir, buildDir, sourceMountDir, goBuildCacheDir, goModCacheDir} {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(jobDir) }()
|
||||
reportBuilderProgress(progress, 14, "env_check: platform builder workspace prepared")
|
||||
if input.ComponentKind == domain.DistributionComponentRun {
|
||||
preparedSourceDir, err := builder.prepareRunSource(ctx, sourceDir, sourceMountDir, input, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
sourceMountDir = preparedSourceDir
|
||||
}
|
||||
@@ -255,66 +292,61 @@ func (builder *DockerDistributionBuilder) BuildWithProgress(input domain.Distrib
|
||||
// through a job-channel response to a machine-side endpoint or a container
|
||||
// command-line argument.
|
||||
if strings.TrimSpace(input.AuthKey) == "" {
|
||||
return nil, validationError("distribution build input is missing a component auth key")
|
||||
return DistributionBuildOutput{}, validationError("distribution build input is missing a component auth key")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "auth-key"), []byte(input.AuthKey), 0o600); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
seedPayload := []byte("[]")
|
||||
if strings.TrimSpace(input.WorkspaceSeed) != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(input.WorkspaceSeed)
|
||||
if err != nil {
|
||||
return nil, validationError("distribution build input has an invalid workspace seed")
|
||||
return DistributionBuildOutput{}, validationError("distribution build input has an invalid workspace seed")
|
||||
}
|
||||
seedPayload = decoded
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "workspace-seed.json"), seedPayload, 0o600); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
lifecyclePlanPayload := []byte("{}")
|
||||
if input.AutonomousLifecycle != nil {
|
||||
encoded, err := json.Marshal(input.AutonomousLifecycle)
|
||||
if err != nil {
|
||||
return nil, validationError("distribution build input has an invalid autonomous lifecycle plan")
|
||||
return DistributionBuildOutput{}, validationError("distribution build input has an invalid autonomous lifecycle plan")
|
||||
}
|
||||
lifecyclePlanPayload = encoded
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "autonomous-lifecycle-plan.json"), lifecyclePlanPayload, 0o600); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(inputDir, "build.sh"), []byte(distributionBuildScript), 0o500); err != nil {
|
||||
return nil, err
|
||||
return DistributionBuildOutput{}, err
|
||||
}
|
||||
|
||||
outputName := strings.TrimSpace(input.OutputFilename)
|
||||
if outputName == "" || filepath.Base(outputName) != outputName {
|
||||
return nil, validationError("distribution build input has an invalid output filename")
|
||||
return DistributionBuildOutput{}, validationError("distribution build input has an invalid output filename")
|
||||
}
|
||||
args := builder.containerArgs(input, sourceMountDir, inputDir, buildDir, outputDir, goBuildCacheDir, goModCacheDir, outputName)
|
||||
reportBuilderProgress(progress, 18, "git_sync: platform builder container starting")
|
||||
output, err := builder.runBuildCommand(ctx, args, progress)
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, validationError("platform builder timed out while building the distribution")
|
||||
return DistributionBuildOutput{}, validationError("platform builder timed out while building the distribution")
|
||||
}
|
||||
return nil, validationError("platform builder failed: " + safeBuilderFailureWithCommandError(output, err, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir))
|
||||
return DistributionBuildOutput{}, validationError("platform builder failed: " + safeBuilderFailureWithCommandError(output, err, input.AuthKey, builder.config.SourceDir, sourceDir, jobDir))
|
||||
}
|
||||
reportBuilderProgress(progress, 92, "package_finalize: reading platform builder output")
|
||||
binary, err := os.ReadFile(filepath.Join(outputDir, outputName))
|
||||
reportBuilderProgress(progress, 92, "package_finalize: validating platform builder output")
|
||||
outputPath := filepath.Join(outputDir, outputName)
|
||||
sizeBytes, checksum, err := fileSizeAndChecksum(outputPath)
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder did not produce a distribution executable")
|
||||
return DistributionBuildOutput{}, validationError("platform builder did not produce a distribution executable")
|
||||
}
|
||||
if len(binary) == 0 {
|
||||
return nil, validationError("platform builder produced an empty distribution executable")
|
||||
if sizeBytes == 0 {
|
||||
return DistributionBuildOutput{}, validationError("platform builder produced an empty distribution executable")
|
||||
}
|
||||
if input.ComponentKind == domain.DistributionComponentRun {
|
||||
return binary, nil
|
||||
}
|
||||
configPayload, err := os.ReadFile(filepath.Join(outputDir, "config.yaml"))
|
||||
if err != nil {
|
||||
return nil, validationError("platform builder did not produce client-manager configuration")
|
||||
}
|
||||
return packageClientManagerDistribution(input.PackageFormat, outputName, binary, configPayload)
|
||||
cleanupJob = false
|
||||
return DistributionBuildOutput{Path: outputPath, SizeBytes: sizeBytes, Checksum: checksum, cleanup: func() error { return os.RemoveAll(jobDir) }}, nil
|
||||
}
|
||||
|
||||
// prepareRunSource implements the source phase of a Jenkins-style build. A
|
||||
@@ -431,6 +463,20 @@ func (builder *DockerDistributionBuilder) runBuildCommand(ctx context.Context, a
|
||||
|
||||
const builderProgressMarker = "__platform_builder_progress__|"
|
||||
|
||||
const maxBuilderDiagnosticOutputBytes = 1024 * 1024
|
||||
|
||||
func appendBoundedBuilderOutput(output *bytes.Buffer, payload []byte) {
|
||||
if len(payload) >= maxBuilderDiagnosticOutputBytes {
|
||||
output.Reset()
|
||||
_, _ = output.Write(payload[len(payload)-maxBuilderDiagnosticOutputBytes:])
|
||||
return
|
||||
}
|
||||
if overflow := output.Len() + len(payload) - maxBuilderDiagnosticOutputBytes; overflow > 0 {
|
||||
_ = output.Next(overflow)
|
||||
}
|
||||
_, _ = output.Write(payload)
|
||||
}
|
||||
|
||||
func parseBuilderProgressLine(line string) (DistributionBuildProgress, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, builderProgressMarker) {
|
||||
@@ -519,36 +565,8 @@ EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$COMPONENT_KIND" != "client-manager" ]; then
|
||||
printf 'unsupported component kind\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$REPOSITORY_URL" in
|
||||
https://*) ;;
|
||||
*) printf 'client-manager repository must use https\n' >&2; exit 2 ;;
|
||||
esac
|
||||
cd /workspace/build
|
||||
progress 24 'git_sync: fetching client-manager source'
|
||||
git init --quiet
|
||||
git remote add origin "$REPOSITORY_URL"
|
||||
git fetch --quiet --depth 1 origin "$SOURCE_REVISION"
|
||||
git checkout --quiet --detach FETCH_HEAD
|
||||
progress 36 'env_check: writing client-manager configuration'
|
||||
{
|
||||
printf 'server_url: "%s"\n' "$PLATFORM_URL"
|
||||
printf 'server_instance_id: "%s"\n' "$SERVER_INSTANCE_ID"
|
||||
printf 'scum_client_credential: "%s"\n' "$auth_key"
|
||||
printf 'scum_client_name: "%s"\n' "$PROFILE_KEY"
|
||||
printf 'scum_client_version: "platform-build"\n'
|
||||
printf 'scum_client_machine_label: "managed-client"\n'
|
||||
printf 'ftp_provider: 3\n'
|
||||
} > config.yaml
|
||||
progress 52 'deps_download: downloading Go modules'
|
||||
go mod download
|
||||
progress 76 'build_compile: compiling client-manager executable'
|
||||
go build -trimpath -ldflags '-s -w' -o "/workspace/output/$OUTPUT_FILENAME" .
|
||||
cp config.yaml /workspace/output/config.yaml
|
||||
progress 88 'package_finalize: client-manager package inputs written'
|
||||
printf 'unsupported component kind\n' >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
func (builder *DockerDistributionBuilder) containerArgs(input domain.DistributionBuildInput, sourceDir string, inputDir string, buildDir string, outputDir string, goBuildCacheDir string, goModCacheDir string, outputName string) []string {
|
||||
@@ -588,71 +606,6 @@ func (builder *DockerDistributionBuilder) containerArgs(input domain.Distributio
|
||||
}
|
||||
}
|
||||
|
||||
func packageClientManagerDistribution(packageFormat string, outputName string, binary []byte, configPayload []byte) ([]byte, error) {
|
||||
switch packageFormat {
|
||||
case "zip":
|
||||
return zipDistributionFiles(outputName, binary, configPayload)
|
||||
case "tar.gz":
|
||||
return tarGzipDistributionFiles(outputName, binary, configPayload)
|
||||
default:
|
||||
return nil, validationError("platform builder received an unsupported client-manager package format")
|
||||
}
|
||||
}
|
||||
|
||||
func zipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
files := []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
payload []byte
|
||||
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
|
||||
for _, file := range files {
|
||||
header := &zip.FileHeader{Name: file.name, Method: zip.Deflate}
|
||||
header.SetMode(file.mode)
|
||||
header.SetModTime(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
entry, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := entry.Write(file.payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func tarGzipDistributionFiles(outputName string, binary []byte, configPayload []byte) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&buffer)
|
||||
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
files := []struct {
|
||||
name string
|
||||
mode int64
|
||||
payload []byte
|
||||
}{{outputName, 0o755, binary}, {"config.yaml", 0o600, configPayload}}
|
||||
for _, file := range files {
|
||||
header := &tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.payload)), ModTime: time.Unix(0, 0).UTC()}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tarWriter.Write(file.payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// safeBuilderFailure keeps host paths and secret values out of reported build
|
||||
// failures.
|
||||
func safeBuilderFailure(output []byte, sensitiveValues ...string) string {
|
||||
|
||||
@@ -2,12 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -312,6 +309,18 @@ func TestDockerDistributionBuilderStreamsProgressMarkers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBoundedBuilderOutputKeepsTail(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
appendBoundedBuilderOutput(&output, bytes.Repeat([]byte("a"), maxBuilderDiagnosticOutputBytes))
|
||||
appendBoundedBuilderOutput(&output, []byte("tail"))
|
||||
if output.Len() != maxBuilderDiagnosticOutputBytes {
|
||||
t.Fatalf("builder diagnostic buffer grew beyond cap: %d", output.Len())
|
||||
}
|
||||
if !strings.HasSuffix(output.String(), "tail") {
|
||||
t.Fatalf("builder diagnostic buffer did not keep tail output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
|
||||
sourceDir := createBuilderSource(t)
|
||||
workspaceDir := t.TempDir()
|
||||
@@ -384,29 +393,6 @@ func TestBuilderJobFailureMessageRedactsSensitiveValuesAndHostPaths(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
|
||||
for _, packageFormat := range []string{"zip", "tar.gz"} {
|
||||
t.Run(packageFormat, func(t *testing.T) {
|
||||
payload, err := packageClientManagerDistribution(packageFormat, "client-manager.exe", []byte("binary"), []byte("credential: protected"))
|
||||
if err != nil {
|
||||
t.Fatalf("package client manager: %v", err)
|
||||
}
|
||||
files := readDistributionArchive(t, packageFormat, payload)
|
||||
if string(files["client-manager.exe"].payload) != "binary" || files["client-manager.exe"].mode.Perm() != 0o755 {
|
||||
t.Fatalf("unexpected executable archive entry: %+v", files["client-manager.exe"])
|
||||
}
|
||||
if string(files["config.yaml"].payload) != "credential: protected" || files["config.yaml"].mode.Perm() != 0o600 {
|
||||
t.Fatalf("unexpected config archive entry: %+v", files["config.yaml"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type distributionArchiveFile struct {
|
||||
mode os.FileMode
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func createBuilderSource(t *testing.T) string {
|
||||
t.Helper()
|
||||
sourceDir := t.TempDir()
|
||||
@@ -443,55 +429,3 @@ func builderMountHostPath(t *testing.T, args []string, containerSuffix string) s
|
||||
t.Fatalf("Docker arguments do not mount %s: %+v", containerSuffix, args)
|
||||
return ""
|
||||
}
|
||||
|
||||
func readDistributionArchive(t *testing.T, packageFormat string, payload []byte) map[string]distributionArchiveFile {
|
||||
t.Helper()
|
||||
files := map[string]distributionArchiveFile{}
|
||||
switch packageFormat {
|
||||
case "zip":
|
||||
reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, entry := range reader.File {
|
||||
body, err := entry.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open zip entry %s: %v", entry.Name, err)
|
||||
}
|
||||
content, err := io.ReadAll(body)
|
||||
if closeErr := body.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read zip entry %s: %v", entry.Name, err)
|
||||
}
|
||||
files[entry.Name] = distributionArchiveFile{mode: entry.Mode(), payload: content}
|
||||
}
|
||||
case "tar.gz":
|
||||
gzipReader, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("open gzip: %v", err)
|
||||
}
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read tar header: %v", err)
|
||||
}
|
||||
content, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read tar entry %s: %v", header.Name, err)
|
||||
}
|
||||
files[header.Name] = distributionArchiveFile{mode: os.FileMode(header.Mode), payload: content}
|
||||
}
|
||||
if err := gzipReader.Close(); err != nil {
|
||||
t.Fatalf("close gzip: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported archive format %q", packageFormat)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -48,6 +50,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||
return domain.RunDistribution{}, validationError(reason)
|
||||
}
|
||||
if _, err := runReleasePlatformURL(); err != nil {
|
||||
return domain.RunDistribution{}, err
|
||||
}
|
||||
|
||||
key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
|
||||
if err != nil {
|
||||
@@ -115,156 +120,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
|
||||
return domain.CopyRunDistribution(distribution), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID string, request domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) {
|
||||
request = domain.CopyClientManagerBuildRequest(request)
|
||||
if strings.TrimSpace(request.IdempotencyKey) == "" {
|
||||
request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch
|
||||
}
|
||||
if err := validator.ValidateClientManagerBuildRequest(request); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", "client-manager.build.denied"); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := validatePluginTarget(plugin, request.TargetOS); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if strings.TrimSpace(request.SourceRevision) == "" {
|
||||
request.SourceRevision = clientManagerProfileRevision(profile)
|
||||
}
|
||||
if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) {
|
||||
return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target")
|
||||
}
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if ready, reason := svc.distributionBuilderReadiness(); !ready {
|
||||
return domain.ClientManagerDistribution{}, validationError(reason)
|
||||
}
|
||||
|
||||
key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
|
||||
if err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
clientDistributionID := distributionID("client-manager-dist", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey)
|
||||
if existing, err := svc.store.ClientManagerDistributions().Get(clientDistributionID); err == nil {
|
||||
return domain.CopyClientManagerDistribution(existing), nil
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
|
||||
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
|
||||
stamp := svc.now()
|
||||
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
|
||||
buildJob := domain.ClientManagerBuildJob{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
Version: profile.Version,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
Status: domain.DistributionJobStatusQueued,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
if err := validator.ValidateClientManagerBuildJob(buildJob); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
|
||||
if !errors.Is(err, repo.ErrDuplicate) {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
|
||||
if getErr != nil {
|
||||
return domain.ClientManagerDistribution{}, getErr
|
||||
}
|
||||
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
|
||||
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
|
||||
}
|
||||
buildJob = existing
|
||||
}
|
||||
distribution := domain.ClientManagerDistribution{
|
||||
ID: clientDistributionID,
|
||||
ServerInstanceID: instance.ID,
|
||||
PluginID: plugin.ID,
|
||||
ProfileKey: request.ProfileKey,
|
||||
Version: profile.Version,
|
||||
TargetOS: request.TargetOS,
|
||||
TargetArch: request.TargetArch,
|
||||
RepositoryURL: request.RepositoryURL,
|
||||
SourceRevision: request.SourceRevision,
|
||||
BuildJobID: buildJob.ID,
|
||||
ArtifactID: artifactID,
|
||||
KeyGeneration: key.Generation,
|
||||
SecretRef: key.SecretRef,
|
||||
Status: domain.DistributionStatusBuilding,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
|
||||
if errors.Is(err, repo.ErrDuplicate) {
|
||||
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
||||
if getErr != nil {
|
||||
return domain.ClientManagerDistribution{}, getErr
|
||||
}
|
||||
return domain.CopyClientManagerDistribution(existing), nil
|
||||
}
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if err := svc.ProjectClientManagerDistribution(distribution); err != nil {
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: buildJobID,
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: platformDistributionBuilderEndpointID,
|
||||
Capability: domain.JobCapabilityDistributionBuild,
|
||||
TargetKey: "distribution/client-manager/" + request.ProfileKey,
|
||||
InputRef: "input://distribution-build/" + distribution.ID,
|
||||
IdempotencyKey: "distribution-build:" + distribution.ID,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
|
||||
})
|
||||
if err != nil {
|
||||
buildJob.Status = domain.DistributionJobStatusFailed
|
||||
buildJob.UpdatedAt = svc.now()
|
||||
distribution.Status = domain.DistributionStatusFailed
|
||||
distribution.UpdatedAt = buildJob.UpdatedAt
|
||||
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
|
||||
_ = svc.store.ClientManagerDistributions().Update(distribution)
|
||||
_ = svc.ProjectClientManagerDistribution(distribution)
|
||||
return domain.ClientManagerDistribution{}, err
|
||||
}
|
||||
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
|
||||
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
|
||||
}
|
||||
svc.enqueueDistributionBuild(job)
|
||||
return domain.CopyClientManagerDistribution(distribution), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID string, serverInstanceID string) (domain.ArtifactDownloadReference, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
@@ -286,31 +141,6 @@ func (svc *CoreService) OpenLatestRunDistributionDownloadForSession(sessionID st
|
||||
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
|
||||
}
|
||||
|
||||
func (svc *CoreService) OpenLatestClientManagerDistributionDownloadForSession(sessionID string, serverInstanceID string, profileKey string) (domain.ArtifactDownloadReference, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
filter := domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable}
|
||||
if strings.TrimSpace(profileKey) != "" {
|
||||
filter.ProfileKey = strings.TrimSpace(profileKey)
|
||||
}
|
||||
distributions, err := svc.store.ClientManagerDistributions().List(filter)
|
||||
if err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
var latest domain.ClientManagerDistribution
|
||||
for _, distribution := range distributions {
|
||||
if latest.ID == "" || distribution.KeyGeneration > latest.KeyGeneration || (distribution.KeyGeneration == latest.KeyGeneration && distribution.UpdatedAt.After(latest.UpdatedAt)) {
|
||||
latest = distribution
|
||||
}
|
||||
}
|
||||
if latest.ID == "" {
|
||||
return domain.ArtifactDownloadReference{}, repo.ErrNotFound
|
||||
}
|
||||
return svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: latest.ArtifactID})
|
||||
}
|
||||
|
||||
func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) {
|
||||
request = domain.CopyComponentKeyResetRequest(request)
|
||||
if err := validator.ValidateComponentKeyResetRequest(request); err != nil {
|
||||
@@ -354,11 +184,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do
|
||||
if err := svc.revokeComponentDistributions(instance.ID, request.ComponentKind, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
}
|
||||
if request.ComponentKind == domain.DistributionComponentClientManager {
|
||||
if err := svc.fenceClientManagerAfterKeyReset(instance.ID, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil {
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
}
|
||||
}
|
||||
if request.ComponentKind == domain.DistributionComponentRun {
|
||||
if err := svc.revokeRunControlSessionForInstance(instance); err != nil {
|
||||
return domain.EncryptedComponentKey{}, err
|
||||
@@ -429,17 +254,6 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
break
|
||||
}
|
||||
}
|
||||
hasAvailableClientPackage := false
|
||||
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable})
|
||||
if err != nil {
|
||||
return domain.ServerRuntimeActions{}, err
|
||||
}
|
||||
for _, distribution := range clientDistributions {
|
||||
if distribution.ArtifactID != "" {
|
||||
hasAvailableClientPackage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID)
|
||||
runPackageInputsComplete, runPackageReason := bindingsComplete, bindingReason
|
||||
if !deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) {
|
||||
@@ -462,18 +276,12 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
|
||||
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
|
||||
runtimeAction("push-run-update", "Push run update", runRegistered && pluginDeclares(plugin, "server.run.distribution") && svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && runPackageInputsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !svc.endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", runPackageReason))),
|
||||
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
|
||||
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && builderReady && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", fallbackReason(!builderReady, builderReason, bindingReason))),
|
||||
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
|
||||
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
|
||||
runtimeAction("dependencies-check", "Check dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)))),
|
||||
runtimeAction("dependencies-install", "Install dependencies", runRegistered && dependencyPermissionDeclared && svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!dependencyPermissionDeclared, "plugin permission is not declared", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityDependenciesInstall), "run endpoint cannot install dependencies", bindingReason)))),
|
||||
runtimeAction("live-logs", "Live logs", runRegistered && pluginSupports(plugin, "logs.read"), fallbackReason(!runRegistered, "Run heartbeat has not been observed", "plugin does not declare live logs")),
|
||||
runtimeAction("historical-logs", "Historical logs", runRegistered && svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!runRegistered, "Run heartbeat has not been observed", fallbackReason(!svc.endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason))),
|
||||
},
|
||||
}
|
||||
if runRegistered {
|
||||
actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...)
|
||||
}
|
||||
return domain.CopyServerRuntimeActions(actions), nil
|
||||
}
|
||||
|
||||
@@ -828,27 +636,6 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki
|
||||
return err
|
||||
}
|
||||
}
|
||||
case domain.DistributionComponentClientManager:
|
||||
distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: componentKey})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, distribution := range distributions {
|
||||
if distribution.KeyGeneration >= latestGeneration || distribution.Status == domain.DistributionStatusRevoked {
|
||||
continue
|
||||
}
|
||||
distribution.Status = domain.DistributionStatusRevoked
|
||||
distribution.UpdatedAt = svc.now()
|
||||
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.expireDistributionArtifact(distribution.ArtifactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -947,22 +734,6 @@ func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte,
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
|
||||
return existing.ServerInstanceID == expected.ServerInstanceID &&
|
||||
existing.PluginID == expected.PluginID &&
|
||||
existing.ProfileKey == expected.ProfileKey &&
|
||||
existing.Version == expected.Version &&
|
||||
existing.TargetOS == expected.TargetOS &&
|
||||
existing.TargetArch == expected.TargetArch &&
|
||||
existing.RepositoryURL == expected.RepositoryURL &&
|
||||
existing.SourceRevision == expected.SourceRevision &&
|
||||
existing.ArtifactID == expected.ArtifactID &&
|
||||
existing.Checksum == expected.Checksum &&
|
||||
existing.KeyGeneration == expected.KeyGeneration &&
|
||||
existing.LogsRef == expected.LogsRef &&
|
||||
existing.Status == expected.Status
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, state domain.DependencyState, message string) error {
|
||||
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
|
||||
stamp := svc.now()
|
||||
@@ -1012,11 +783,28 @@ func packageFormatForTarget(targetOS string) string {
|
||||
return "tar.gz"
|
||||
}
|
||||
|
||||
func runReleasePlatformURL() string {
|
||||
if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" {
|
||||
return value
|
||||
const defaultRunReleasePlatformURL = "https://scum.npc0.com/"
|
||||
|
||||
func runReleasePlatformURL() (string, error) {
|
||||
value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL"))
|
||||
if value == "" {
|
||||
value = defaultRunReleasePlatformURL
|
||||
}
|
||||
return "https://scum.npc0.com/"
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(value, "#") {
|
||||
return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL without credentials, query parameters, or fragments")
|
||||
}
|
||||
host := strings.TrimRight(strings.ToLower(parsed.Hostname()), ".")
|
||||
if host == "" {
|
||||
return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL with a host")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) {
|
||||
return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func distributionID(prefix string, parts ...interface{}) string {
|
||||
@@ -1088,14 +876,6 @@ func fingerprintForString(value string) string {
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func clientManagerOutputName(profileKey string, targetOS string) string {
|
||||
name := sanitizeIDPart(profileKey)
|
||||
if targetOS == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
@@ -23,6 +23,94 @@ type generatedPackageConfig struct {
|
||||
AuthKey string
|
||||
}
|
||||
|
||||
func TestRunReleasePlatformURLUsesExternalDefault(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", "")
|
||||
|
||||
value, err := runReleasePlatformURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve default Run release URL: %v", err)
|
||||
}
|
||||
if value != defaultRunReleasePlatformURL {
|
||||
t.Fatalf("unexpected default Run release URL %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReleasePlatformURLRejectsTargetLocalAddresses(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"http://127.0.0.1:18080",
|
||||
"http://127.0.0.2:18080",
|
||||
"http://localhost:18080",
|
||||
"https://platform.localhost/",
|
||||
"http://[::1]:18080",
|
||||
"http://0.0.0.0:18080",
|
||||
"http://[::]:18080",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", value)
|
||||
if _, err := runReleasePlatformURL(); err == nil || !strings.Contains(err.Error(), "reachable from the target server") {
|
||||
t.Fatalf("expected target-local URL %q to be rejected, got %v", value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReleasePlatformURLAcceptsExternalHTTPURL(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", "http://platform.example.test:8080/control")
|
||||
|
||||
value, err := runReleasePlatformURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve external Run release URL: %v", err)
|
||||
}
|
||||
if value != "http://platform.example.test:8080/control" {
|
||||
t.Fatalf("unexpected external Run release URL %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReleasePlatformURLRejectsInvalidURLForms(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"ftp://platform.example.test",
|
||||
"https://operator@platform.example.test",
|
||||
"https://platform.example.test/?token=value",
|
||||
"https://platform.example.test/?",
|
||||
"https://platform.example.test/#fragment",
|
||||
"https://platform.example.test/#",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", value)
|
||||
if _, err := runReleasePlatformURL(); err == nil || !strings.Contains(err.Error(), "absolute http or https URL") {
|
||||
t.Fatalf("expected invalid Run release URL %q to be rejected, got %v", value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsLoopbackRunReleaseURLBeforeGeneration(t *testing.T) {
|
||||
t.Setenv("PLATFORM_RUN_RELEASE_URL", "http://127.0.0.1:18080")
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
|
||||
_, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "loopback-release-url",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "reachable from the target server") {
|
||||
t.Fatalf("expected loopback Run release URL to fail generation, got %v", err)
|
||||
}
|
||||
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(distributions) != 0 {
|
||||
t.Fatalf("invalid release URL must not create distributions, distributions=%+v err=%v", distributions, err)
|
||||
}
|
||||
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentRun})
|
||||
if err != nil || len(keys) != 0 {
|
||||
t.Fatalf("invalid release URL must not create component keys, keys=%+v err=%v", keys, err)
|
||||
}
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil || len(jobs) != 0 {
|
||||
t.Fatalf("invalid release URL must not create build jobs, jobs=%+v err=%v", jobs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
|
||||
@@ -511,88 +599,25 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperations(t *testing.T) {
|
||||
func TestCoreServiceRejectsClientManagerComponentRequests(t *testing.T) {
|
||||
svc, session, instance := newDistributionTestFixture(t)
|
||||
runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "idem-run-for-client",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate run distribution: %v", err)
|
||||
}
|
||||
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
|
||||
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
|
||||
t.Fatalf("open run download: %v", err)
|
||||
}
|
||||
runConfig := readGeneratedPackageConfig(t, svc, session, runDistribution.ArtifactID)
|
||||
|
||||
clientDistribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
SourceRevision: "main",
|
||||
IdempotencyKey: "idem-client-manager",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate client-manager distribution: %v", err)
|
||||
}
|
||||
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
|
||||
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
|
||||
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
|
||||
}
|
||||
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
|
||||
t.Fatalf("client-manager must use a distinct key/ref, run=%+v client=%+v", runConfig, clientConfig)
|
||||
}
|
||||
build, err := svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
|
||||
if err != nil {
|
||||
t.Fatalf("get build job: %v", err)
|
||||
}
|
||||
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
|
||||
t.Fatalf("unexpected build job: %+v", build)
|
||||
}
|
||||
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
|
||||
build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
|
||||
if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
|
||||
t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
|
||||
}
|
||||
|
||||
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "darwin",
|
||||
TargetArch: "amd64",
|
||||
RepositoryURL: "https://github.com/F88888/scum_client.git",
|
||||
IdempotencyKey: "idem-client-manager-denied",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "targetOs") {
|
||||
t.Fatalf("expected unsupported target denial, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ComponentKind: domain.DistributionComponentKind("client-manager"),
|
||||
ComponentKey: "scum-client-manager",
|
||||
}); err != nil {
|
||||
t.Fatalf("reset client-manager key: %v", err)
|
||||
}); err == nil || !strings.Contains(err.Error(), "componentKind") {
|
||||
t.Fatalf("expected client-manager key reset to be rejected, got %v", err)
|
||||
}
|
||||
auth, err := svc.AuthenticateComponent(domain.ComponentAuthenticationRequest{
|
||||
ServerInstanceID: instance.ID,
|
||||
ComponentKind: domain.DistributionComponentClientManager,
|
||||
ComponentKind: domain.DistributionComponentKind("client-manager"),
|
||||
ComponentKey: "scum-client-manager",
|
||||
Generation: clientConfig.KeyGeneration,
|
||||
Key: clientConfig.AuthKey,
|
||||
Generation: 1,
|
||||
Key: "legacy-client-manager-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authenticate old client key: %v", err)
|
||||
if err == nil || auth.Allowed {
|
||||
t.Fatalf("expected client-manager component authentication to be rejected, auth=%+v err=%v", auth, err)
|
||||
}
|
||||
if auth.Allowed {
|
||||
t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) {
|
||||
@@ -602,7 +627,6 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
plugin.SupportedOS = []string{"linux", "windows"}
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions,
|
||||
"server.run.distribution",
|
||||
"server.client-manager.manage",
|
||||
"server.dependencies.manage",
|
||||
)
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities,
|
||||
@@ -610,22 +634,15 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
)
|
||||
plugin.BridgeActions = append(plugin.BridgeActions,
|
||||
string(domain.PluginBridgeActionRunDistribution),
|
||||
string(domain.PluginBridgeActionClientManager),
|
||||
string(domain.PluginBridgeActionDependenciesRequest),
|
||||
string(domain.PluginBridgeActionLogsBackfillRequest),
|
||||
)
|
||||
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update plugin fixture: %v", err)
|
||||
}
|
||||
@@ -634,11 +651,6 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityLogsBackfill,
|
||||
domain.JobCapabilityClientManagerDeploy,
|
||||
domain.JobCapabilityClientManagerControl,
|
||||
domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback,
|
||||
domain.JobCapabilityClientManagerUninstall,
|
||||
)
|
||||
endpoint.Platform = "linux"
|
||||
endpoint.Architecture = "amd64"
|
||||
@@ -712,24 +724,6 @@ func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string,
|
||||
}
|
||||
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
|
||||
}
|
||||
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list client distributions: %v", err)
|
||||
}
|
||||
for _, distribution := range clients {
|
||||
if distribution.ArtifactID != artifactID {
|
||||
continue
|
||||
}
|
||||
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
|
||||
if err != nil {
|
||||
t.Fatalf("get client key: %v", err)
|
||||
}
|
||||
plain, err := decryptRuntimeKey(key.EncryptedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt client key: %v", err)
|
||||
}
|
||||
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
|
||||
}
|
||||
t.Fatalf("distribution for artifact %s was not found", artifactID)
|
||||
return generatedPackageConfig{}
|
||||
}
|
||||
@@ -754,26 +748,6 @@ func completeDistributionBuild(t *testing.T, svc *CoreService, distribution doma
|
||||
return domain.RunDistribution{}
|
||||
}
|
||||
|
||||
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, _ []byte) domain.ClientManagerDistribution {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get client distribution: %v", err)
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusAvailable {
|
||||
return updated
|
||||
}
|
||||
if updated.Status == domain.DistributionStatusFailed {
|
||||
t.Fatalf("platform client build failed: %+v", updated)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("platform client build did not complete")
|
||||
return domain.ClientManagerDistribution{}
|
||||
}
|
||||
|
||||
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,15 +12,8 @@ import (
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const defaultGameClientBridgeLeaseDuration = 60 * time.Second
|
||||
|
||||
const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour
|
||||
|
||||
type gameClientBridgeComponentSession struct {
|
||||
Session domain.ClientManagerSession
|
||||
Installation domain.ClientManagerInstallation
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
@@ -56,40 +48,20 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
}
|
||||
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
|
||||
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeStatus{}, err
|
||||
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
|
||||
if !gameClientBridgeDeclared(plugin.GameClientBridge) {
|
||||
return domain.CopyGameClientBridgeStatus(status), nil
|
||||
}
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if !containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
continue
|
||||
}
|
||||
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: profile.Key, Reason: "compatible companion session is offline", CommandTypes: gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands), SnapshotTypes: gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots), QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates)}
|
||||
for _, installation := range installations {
|
||||
if installation.ProfileKey != profile.Key || (installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded) || installation.RequiresRedeploy {
|
||||
continue
|
||||
}
|
||||
sessions, listErr := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
|
||||
if listErr != nil {
|
||||
return domain.GameClientBridgeStatus{}, listErr
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
|
||||
declaration.Available = true
|
||||
declaration.Reason = ""
|
||||
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
|
||||
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
status.Profiles = append(status.Profiles, declaration)
|
||||
status.Available = status.Available || declaration.Available
|
||||
}
|
||||
if len(status.Profiles) > 0 {
|
||||
status.Reason = "no compatible companion session is online"
|
||||
commandTypes := gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands)
|
||||
snapshotTypes := gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots)
|
||||
declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: "plugin-owned", Available: true, CommandTypes: commandTypes, SnapshotTypes: snapshotTypes, QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates), HandlerTypes: commandTypes, EventProducerTypes: snapshotTypes}
|
||||
if ready, reason := svc.gameClientBridgeRuntimeReadiness(instance, plugin); !ready {
|
||||
declaration.Available = false
|
||||
declaration.Reason = reason
|
||||
status.Reason = reason
|
||||
}
|
||||
status.Profiles = append(status.Profiles, declaration)
|
||||
status.Available = declaration.Available
|
||||
if status.Available {
|
||||
status.Reason = ""
|
||||
}
|
||||
@@ -97,15 +69,37 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
|
||||
return domain.CopyGameClientBridgeStatus(status), nil
|
||||
}
|
||||
|
||||
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
|
||||
values := make([]string, 0, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
|
||||
values = append(values, value)
|
||||
func gameClientBridgeDeclared(bridge domain.GameClientBridgeManifest) bool {
|
||||
return len(bridge.Commands) > 0 || len(bridge.Snapshots) > 0 || len(bridge.QueryTemplates) > 0 || len(bridge.LifecycleProjections) > 0 || len(bridge.DataPacks) > 0 || len(bridge.Pages) > 0 || len(bridge.Features) > 0
|
||||
}
|
||||
|
||||
func (svc *CoreService) gameClientBridgeRuntimeReadiness(instance domain.ServerInstance, plugin domain.GamePlugin) (bool, string) {
|
||||
if len(plugin.GameClientBridge.QueryTemplates) == 0 {
|
||||
return true, ""
|
||||
}
|
||||
endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return false, "Run endpoint is not registered"
|
||||
}
|
||||
if endpoint.Status == domain.RunEndpointStatusOffline || endpoint.Status == domain.RunEndpointStatusDisabled {
|
||||
return false, "Run heartbeat has not been observed"
|
||||
}
|
||||
transports := map[string]domain.RuntimeTransportProfile{}
|
||||
for _, transport := range plugin.RuntimeProfiles.TransportProfiles {
|
||||
transports[transport.Key] = transport
|
||||
}
|
||||
for _, template := range plugin.GameClientBridge.QueryTemplates {
|
||||
transport, ok := transports[template.TransportKey]
|
||||
if !ok {
|
||||
return false, "bridge query transport is not declared"
|
||||
}
|
||||
for _, capability := range transport.Capabilities {
|
||||
if !svc.endpointSupports(endpoint, capability) {
|
||||
return false, "Run endpoint is missing bridge transport capability"
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
|
||||
@@ -136,7 +130,7 @@ func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatu
|
||||
}
|
||||
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
|
||||
if len(missing) > 0 {
|
||||
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
|
||||
availability.Reason = "plugin-owned bridge producer is missing " + strings.Join(missing, ", ")
|
||||
}
|
||||
result = append(result, availability)
|
||||
}
|
||||
@@ -204,14 +198,7 @@ func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID strin
|
||||
}
|
||||
|
||||
func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) {
|
||||
profileDeclared := false
|
||||
for _, profile := range plugin.RuntimeProfiles.ClientManagers {
|
||||
if profile.Key == profileKey && containsString(profile.Health.RequiredCapabilities, gameClientBridgeCapability) {
|
||||
profileDeclared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileDeclared {
|
||||
if strings.TrimSpace(profileKey) == "" {
|
||||
return domain.GameClientBridgeCommandDeclaration{}, false
|
||||
}
|
||||
for _, declaration := range plugin.GameClientBridge.Commands {
|
||||
@@ -243,6 +230,15 @@ func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapsho
|
||||
return values
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeSnapshotDeclaration{}, false
|
||||
}
|
||||
|
||||
func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string {
|
||||
values := make([]string, len(declarations))
|
||||
for index, declaration := range declarations {
|
||||
@@ -306,113 +302,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridgeComponentSession, limit int) ([]domain.GameClientBridgeCommand, error) {
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit < 1 || limit > 50 {
|
||||
return nil, validationError("bridge claim limit must be between 1 and 50")
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, State: domain.GameClientBridgeCommandPending})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(commands, func(left, right int) bool {
|
||||
if commands[left].Priority != commands[right].Priority {
|
||||
return commands[left].Priority > commands[right].Priority
|
||||
}
|
||||
if !commands[left].CreatedAt.Equal(commands[right].CreatedAt) {
|
||||
return commands[left].CreatedAt.Before(commands[right].CreatedAt)
|
||||
}
|
||||
return commands[left].ID < commands[right].ID
|
||||
})
|
||||
claimed := make([]domain.GameClientBridgeCommand, 0, limit)
|
||||
for _, command := range commands {
|
||||
if len(claimed) == limit {
|
||||
break
|
||||
}
|
||||
if command.RunJobID != "" {
|
||||
continue
|
||||
}
|
||||
fencingToken := command.Claim.FencingToken + 1
|
||||
command.State = domain.GameClientBridgeCommandClaimed
|
||||
command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp}
|
||||
command.UpdatedAt = stamp
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed = append(claimed, domain.CopyGameClientBridgeCommand(command))
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
command.Claim.AcknowledgedAt = stamp
|
||||
command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt)
|
||||
command.UpdatedAt = stamp
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBridgeComponentSession, request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
if command.Result.CompletedBy == component.Session.ID && gameClientBridgeClaimMatches(command, component, request.FencingToken) && command.Result.Status == request.Status && command.Result.Summary == request.Summary && reflect.DeepEqual(command.Result.Payload, request.Payload) {
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command already has a terminal result")
|
||||
}
|
||||
command, err = svc.fencedGameClientBridgeCommand(component, request.CommandID, request.FencingToken, stamp)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
switch request.Status {
|
||||
case domain.GameClientBridgeResultSucceeded:
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
case domain.GameClientBridgeResultFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
case domain.GameClientBridgeResultUnknown:
|
||||
command.State = domain.GameClientBridgeCommandUnknown
|
||||
case domain.GameClientBridgeResultCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
}
|
||||
command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp}
|
||||
command.CompletedAt = stamp
|
||||
command.UpdatedAt = stamp
|
||||
if err := svc.store.GameClientBridgeCommands().Update(command); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.CopyGameClientBridgeCommand(command), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
@@ -595,26 +484,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) fencedGameClientBridgeCommand(component gameClientBridgeComponentSession, commandID string, fencingToken uint64, stamp time.Time) (domain.GameClientBridgeCommand, error) {
|
||||
command, err := svc.store.GameClientBridgeCommands().Get(commandID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
if command.State != domain.GameClientBridgeCommandClaimed || !gameClientBridgeClaimMatches(command, component, fencingToken) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim is stale")
|
||||
}
|
||||
if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) {
|
||||
if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expired")
|
||||
}
|
||||
if !command.Claim.LeaseExpiresAt.After(stamp) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command claim lease expired")
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error {
|
||||
if isTerminalGameClientBridgeCommandState(command.State) {
|
||||
return nil
|
||||
@@ -625,18 +494,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC
|
||||
return svc.store.GameClientBridgeCommands().Update(command)
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimLeaseExpiry(stamp, commandExpiry time.Time) time.Time {
|
||||
leaseExpiry := stamp.Add(defaultGameClientBridgeLeaseDuration)
|
||||
if !commandExpiry.IsZero() && commandExpiry.Before(leaseExpiry) {
|
||||
return commandExpiry
|
||||
}
|
||||
return leaseExpiry
|
||||
}
|
||||
|
||||
func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, component gameClientBridgeComponentSession, fencingToken uint64) bool {
|
||||
return command.Claim.SessionID == component.Session.ID && command.Claim.InstallationID == component.Installation.ID && command.Claim.DeploymentGeneration == component.Session.DeploymentGeneration && command.Claim.FencingToken == fencingToken
|
||||
}
|
||||
|
||||
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||
switch state {
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const gameClientBridgeCapability = "game-client.bridge"
|
||||
|
||||
func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.claimGameClientBridgeCommands(component, request.Limit)
|
||||
}
|
||||
|
||||
func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.ackGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
|
||||
if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
return svc.completeGameClientBridgeCommand(component, request)
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
component, err := svc.authorizeGameClientBridgeSession(request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
stamp := svc.now()
|
||||
if request.ObservedAt.After(stamp.Add(5 * time.Minute)) {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future")
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion)
|
||||
if !declared {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared")
|
||||
}
|
||||
if request.Retention != declaration.Retention {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration")
|
||||
}
|
||||
svc.bridgeMu.Lock()
|
||||
defer svc.bridgeMu.Unlock()
|
||||
|
||||
streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey)
|
||||
latestSequence := uint64(0)
|
||||
stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID)
|
||||
if streamErr == nil {
|
||||
latestSequence = stream.LatestSequence
|
||||
} else if streamErr != repo.ErrNotFound {
|
||||
return domain.GameClientBridgeSnapshot{}, streamErr
|
||||
}
|
||||
existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey})
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
for _, snapshot := range existing {
|
||||
if snapshot.Sequence > latestSequence {
|
||||
latestSequence = snapshot.Sequence
|
||||
}
|
||||
}
|
||||
if request.Sequence <= latestSequence {
|
||||
return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale")
|
||||
}
|
||||
svc.bridgeSeq++
|
||||
snapshot := domain.GameClientBridgeSnapshot{
|
||||
ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq),
|
||||
ServerInstanceID: component.Session.ServerInstanceID,
|
||||
PluginID: component.Installation.PluginID,
|
||||
ProfileKey: component.Session.ProfileKey,
|
||||
Type: request.Type,
|
||||
SchemaVersion: request.SchemaVersion,
|
||||
StreamKey: request.StreamKey,
|
||||
Sequence: request.Sequence,
|
||||
SourceSessionID: component.Session.ID,
|
||||
ObservedAt: request.ObservedAt,
|
||||
Payload: domain.CopyGameClientBridgePayload(request.Payload),
|
||||
Retention: request.Retention,
|
||||
CreatedAt: stamp,
|
||||
ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second),
|
||||
}
|
||||
if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp}
|
||||
if streamErr == repo.ErrNotFound {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
} else {
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil {
|
||||
return domain.GameClientBridgeSnapshot{}, err
|
||||
}
|
||||
}
|
||||
return domain.CopyGameClientBridgeSnapshot(snapshot), nil
|
||||
}
|
||||
|
||||
func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) {
|
||||
for _, declaration := range plugin.GameClientBridge.Snapshots {
|
||||
if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion {
|
||||
return declaration, true
|
||||
}
|
||||
}
|
||||
return domain.GameClientBridgeSnapshotDeclaration{}, false
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) {
|
||||
presentedHash := tokenHash(sessionToken)
|
||||
sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive})
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, err
|
||||
}
|
||||
var session domain.ClientManagerSession
|
||||
for _, candidate := range sessions {
|
||||
if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 {
|
||||
session = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
stamp := svc.now()
|
||||
if session.ID == "" || !stamp.Before(session.ExpiresAt) {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if !containsString(session.Capabilities, gameClientBridgeCapability) {
|
||||
return gameClientBridgeComponentSession{}, ErrForbidden
|
||||
}
|
||||
installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID)
|
||||
if err != nil {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey)
|
||||
if err != nil || key.Generation != session.KeyGeneration {
|
||||
return gameClientBridgeComponentSession{}, ErrUnauthorized
|
||||
}
|
||||
return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil
|
||||
}
|
||||
|
||||
func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string {
|
||||
digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey))
|
||||
return "bridge-stream-" + hex.EncodeToString(digest[:16])
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func seedGameClientBridgeComponentSession(t *testing.T, svc *CoreService, now time.Time, token string) (domain.ClientManagerInstallation, domain.ClientManagerSession) {
|
||||
t.Helper()
|
||||
installation := domain.ClientManagerInstallation{ID: "installation-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", RunEndpointID: "run-1", Status: domain.ClientManagerLifecycleOnline, ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3}
|
||||
session := domain.ClientManagerSession{ID: "component-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: []string{"component.heartbeat", gameClientBridgeCapability}, Status: domain.ClientManagerSessionActive, ExpiresAt: now.Add(time.Hour)}
|
||||
key := domain.EncryptedComponentKey{ID: "key-1", ServerInstanceID: installation.ServerInstanceID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: installation.ProfileKey, Generation: installation.KeyGeneration, Status: domain.ComponentKeyStatusActive}
|
||||
if err := svc.store.ClientManagerInstallations().Create(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.ClientManagerSessions().Create(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.store.EncryptedComponentKeys().Create(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installation, session
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionAuthorizesCommandsAndSnapshots(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
_, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authorized-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token, Limit: 5})
|
||||
if err != nil || len(claimed) != 1 || claimed[0].ID != command.ID || claimed[0].Claim.SessionID != session.ID {
|
||||
t.Fatalf("authorized claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("authorized ack: %v", err)
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: token, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
|
||||
t.Fatalf("authorized result: %v", err)
|
||||
}
|
||||
|
||||
snapshotRequest := domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}
|
||||
snapshot, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest)
|
||||
if err != nil || snapshot.SourceSessionID != session.ID || snapshot.Sequence != 1 {
|
||||
t.Fatalf("authorized snapshot: %#v err=%v", snapshot, err)
|
||||
}
|
||||
if snapshot.SourceSessionID == token {
|
||||
t.Fatal("raw component token persisted in snapshot")
|
||||
}
|
||||
higherSnapshotRequest := snapshotRequest
|
||||
higherSnapshotRequest.Sequence = 2
|
||||
higherSnapshot, err := svc.UploadGameClientBridgeSnapshot(higherSnapshotRequest)
|
||||
if err != nil || higherSnapshot.Sequence != 2 {
|
||||
t.Fatalf("higher snapshot sequence was not accepted: %#v err=%v", higherSnapshot, err)
|
||||
}
|
||||
equalSnapshotRequest := higherSnapshotRequest
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(equalSnapshotRequest); err == nil {
|
||||
t.Fatal("expected latest snapshot sequence to be rejected")
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(snapshotRequest); err == nil {
|
||||
t.Fatal("expected lower snapshot sequence rejection")
|
||||
}
|
||||
currentSnapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current"})
|
||||
if err != nil || len(currentSnapshots) != 2 {
|
||||
t.Fatalf("stale snapshot attempts changed current stream records: %#v err=%v", currentSnapshots, err)
|
||||
}
|
||||
isolatedStreamRequest := snapshotRequest
|
||||
isolatedStreamRequest.StreamKey = "secondary"
|
||||
if snapshot, err := svc.UploadGameClientBridgeSnapshot(isolatedStreamRequest); err != nil || snapshot.Sequence != 1 {
|
||||
t.Fatalf("independent snapshot stream did not start at sequence one: %#v err=%v", snapshot, err)
|
||||
}
|
||||
stream, err := svc.store.GameClientBridgeSnapshotStreams().Get(gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"))
|
||||
if err != nil || stream.LatestSequence != 2 {
|
||||
t.Fatalf("snapshot stream projection: %#v err=%v", stream, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimCannotBeCompletedByAnotherCurrentSession(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const firstToken = "component-session-token-one"
|
||||
_, firstSession := seedGameClientBridgeComponentSession(t, svc, *clock, firstToken)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "session-owner-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: firstToken, Limit: 1})
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("first session claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
const secondToken = "component-session-token-two"
|
||||
secondSession := firstSession
|
||||
secondSession.ID = "component-session-2"
|
||||
secondSession.TokenHash = tokenHash(secondToken)
|
||||
if err := svc.store.ClientManagerSessions().Create(secondSession); err != nil {
|
||||
t.Fatalf("create second current session: %v", err)
|
||||
}
|
||||
request := domain.GameClientBridgeAckRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}
|
||||
if _, err := svc.AckGameClientBridgeCommand(request); err == nil {
|
||||
t.Fatal("expected second session ack to be rejected")
|
||||
}
|
||||
if _, err := svc.CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest{SessionToken: secondToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected second session result to be rejected")
|
||||
}
|
||||
if _, err := svc.AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest{SessionToken: firstToken, CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err != nil {
|
||||
t.Fatalf("claim owner could not ack after rejected second session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeComponentSessionRejectsMissingCapabilityAndStaleFences(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
const token = "component-session-token"
|
||||
installation, session := seedGameClientBridgeComponentSession(t, svc, *clock, token)
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "authz-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.Capabilities = []string{"component.heartbeat"}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected missing capability rejection, got %v", err)
|
||||
}
|
||||
session.Capabilities = []string{"component.heartbeat", gameClientBridgeCapability}
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.DeploymentGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected deployment fence rejection, got %v", err)
|
||||
}
|
||||
installation.DeploymentGeneration = session.DeploymentGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.ActiveArtifactID = "artifact-2"
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active artifact fence rejection, got %v", err)
|
||||
}
|
||||
installation.ActiveArtifactID = session.ArtifactID
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installation.KeyGeneration++
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected installation key-generation fence rejection, got %v", err)
|
||||
}
|
||||
installation.KeyGeneration = session.KeyGeneration
|
||||
if err := svc.store.ClientManagerInstallations().Update(installation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key, err := svc.store.EncryptedComponentKeys().Get("key-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key.Generation++
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest{SessionToken: token}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected active component key-generation rejection, got %v", err)
|
||||
}
|
||||
key.Generation = session.KeyGeneration
|
||||
if err := svc.store.EncryptedComponentKeys().Update(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session.ExpiresAt = *clock
|
||||
if err := svc.store.ClientManagerSessions().Update(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: token, Type: "health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: *clock, Payload: map[string]any{"healthy": true}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expected expired session rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := repo.NewMemoryStore()
|
||||
plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
plugin := domain.GamePlugin{ID: "game.scum", GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}}
|
||||
if err := store.GamePlugins().Create(plugin); err != nil {
|
||||
t.Fatalf("seed bridge plugin: %v", err)
|
||||
}
|
||||
@@ -21,23 +21,19 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) {
|
||||
}
|
||||
|
||||
func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest {
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)}
|
||||
}
|
||||
|
||||
func bridgeComponent() gameClientBridgeComponentSession {
|
||||
return gameClientBridgeComponentSession{
|
||||
Session: domain.ClientManagerSession{ID: "component-session-1", ServerInstanceID: "server-1", ProfileKey: "scum-client", DeploymentGeneration: 3},
|
||||
Installation: domain.ClientManagerInstallation{ID: "installation-1", PluginID: "game.scum"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
func TestGameClientBridgeCommandQueueAndIdempotency(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "announce-1")
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue bridge command: %v", err)
|
||||
}
|
||||
if command.State != domain.GameClientBridgeCommandPending || command.Claim.SessionID != "" {
|
||||
t.Fatalf("queued command should remain unclaimed: %#v", command)
|
||||
}
|
||||
duplicate, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil || duplicate.ID != command.ID {
|
||||
t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err)
|
||||
@@ -46,36 +42,6 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected one durable command: %#v", commands)
|
||||
}
|
||||
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 10)
|
||||
if err != nil || len(claimed) != 1 || claimed[0].State != domain.GameClientBridgeCommandClaimed || claimed[0].Claim.FencingToken != 1 {
|
||||
t.Fatalf("claim bridge command: %#v err=%v", claimed, err)
|
||||
}
|
||||
initialLeaseExpiry := claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 2}); err == nil {
|
||||
t.Fatal("expected stale fencing token rejection")
|
||||
}
|
||||
*clock = clock.Add(10 * time.Second)
|
||||
acked, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1})
|
||||
if err != nil || acked.Claim.AcknowledgedAt.IsZero() || !acked.Claim.LeaseExpiresAt.Equal(clock.Add(defaultGameClientBridgeLeaseDuration)) || !acked.Claim.LeaseExpiresAt.After(initialLeaseExpiry) {
|
||||
t.Fatalf("ack bridge command: %#v err=%v", acked, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Payload: map[string]any{"sessionToken": "must-not-persist"}}); err == nil {
|
||||
t.Fatal("expected unsafe result material to be rejected")
|
||||
}
|
||||
resultRequest := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered", Payload: map[string]any{"delivered": true}}
|
||||
completed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||
if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() {
|
||||
t.Fatalf("complete bridge command: %#v err=%v", completed, err)
|
||||
}
|
||||
replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest)
|
||||
if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) {
|
||||
t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil {
|
||||
t.Fatal("expected conflicting terminal result rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
@@ -107,154 +73,19 @@ func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeLeaseReclaimAndExpiry(t *testing.T) {
|
||||
func TestGameClientBridgeReconciliationExpiresPendingCommand(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "lease-1"))
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "expires-1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
first, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("first claim: %#v err=%v", first, err)
|
||||
}
|
||||
*clock = clock.Add(defaultGameClientBridgeLeaseDuration + time.Second)
|
||||
second, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(second) != 1 || second[0].Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaim expired lease: %#v err=%v", second, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected old claim fencing rejection")
|
||||
}
|
||||
*clock = command.ExpiresAt.Add(time.Second)
|
||||
if err := svc.ReconcileGameClientBridgeCommands(); err != nil {
|
||||
t.Fatalf("reconcile expired command: %v", err)
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired {
|
||||
t.Fatalf("expected expired command: %#v err=%v", expired, err)
|
||||
}
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("expired command was claimable: %#v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "pending-expiry")
|
||||
request.ExpiresAt = clock.Add(30 * time.Second)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
*clock = request.ExpiresAt
|
||||
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
|
||||
if err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err)
|
||||
}
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 {
|
||||
t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
for _, key := range []string{"expired-lease-ack", "expired-lease-result"} {
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, key)); err != nil {
|
||||
t.Fatalf("queue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim lease-expiry commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
*clock = claimed[0].Claim.LeaseExpiresAt
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at claim lease expiry to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at claim lease expiry to be rejected")
|
||||
}
|
||||
for _, command := range claimed {
|
||||
protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken {
|
||||
t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
reclaimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(reclaimed) != 2 {
|
||||
t.Fatalf("reclaim protected commands after lease sweep: %#v err=%v", reclaimed, err)
|
||||
}
|
||||
for _, command := range reclaimed {
|
||||
if command.Claim.FencingToken != 2 {
|
||||
t.Fatalf("reclaimed command did not advance fencing token: %#v", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
deadline := clock.Add(30 * time.Second)
|
||||
commands := make([]domain.GameClientBridgeCommand, 0, 2)
|
||||
for _, key := range []string{"deadline-ack", "deadline-result"} {
|
||||
request := bridgeQueueRequest(*clock, key)
|
||||
request.ExpiresAt = deadline
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue deadline command: %v", err)
|
||||
}
|
||||
commands = append(commands, command)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 2)
|
||||
if err != nil || len(claimed) != 2 {
|
||||
t.Fatalf("claim deadline commands: %#v err=%v", claimed, err)
|
||||
}
|
||||
for _, command := range claimed {
|
||||
if !command.Claim.LeaseExpiresAt.Equal(deadline) {
|
||||
t.Fatalf("claim lease exceeded command deadline: %#v", command.Claim)
|
||||
}
|
||||
}
|
||||
*clock = deadline
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected ack at command deadline to be rejected")
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[1].ID, FencingToken: claimed[1].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded}); err == nil {
|
||||
t.Fatal("expected result at command deadline to be rejected")
|
||||
}
|
||||
for _, command := range commands {
|
||||
expired, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() {
|
||||
t.Fatalf("deadline mutation did not persist expiry: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", bridgeQueueRequest(*clock, "failed-result"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim failed-result command: %#v err=%v", claimed, err)
|
||||
}
|
||||
request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}}
|
||||
failed, err := svc.completeGameClientBridgeCommand(component, request)
|
||||
if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() {
|
||||
t.Fatalf("record failed result: %#v err=%v", failed, err)
|
||||
}
|
||||
persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID)
|
||||
if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) {
|
||||
t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err)
|
||||
if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.Claim.SessionID != "" {
|
||||
t.Fatalf("expected expired unclaimed command: %#v err=%v", expired, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +118,7 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
||||
func TestGameClientBridgeOperatorCancellationIsIdempotentAndTerminal(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
user := domain.User{ID: "user-1", DisplayName: "Owner", Roles: []string{"server-owner"}, Status: domain.UserStatusActive, CreatedAt: *clock, UpdatedAt: *clock}
|
||||
if err := svc.store.Users().Create(user); err != nil {
|
||||
@@ -304,29 +135,14 @@ func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
component := bridgeComponent()
|
||||
claimed, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim: %#v err=%v", claimed, err)
|
||||
}
|
||||
cancelled, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID {
|
||||
if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID || cancelled.Result.Status != domain.GameClientBridgeResultCancelled {
|
||||
t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err)
|
||||
}
|
||||
repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"})
|
||||
if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) {
|
||||
t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err)
|
||||
}
|
||||
remaining, err := svc.claimGameClientBridgeCommands(component, 1)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("cancelled command remained claimable: %#v err=%v", remaining, err)
|
||||
}
|
||||
if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "late success"}); err == nil {
|
||||
t.Fatal("expected late success after cancellation rejection")
|
||||
}
|
||||
if _, err := svc.ackGameClientBridgeCommand(component, domain.GameClientBridgeAckRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken}); err == nil {
|
||||
t.Fatal("expected late ack after cancellation rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequence(t *testing.T) {
|
||||
@@ -345,7 +161,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
|
||||
t.Fatal(err)
|
||||
}
|
||||
for sequence := uint64(1); sequence <= 4; sequence++ {
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
|
||||
snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-" + string(rune('0'+sequence)), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 2}, ExpiresAt: clock.Add(time.Hour)}
|
||||
if sequence == 1 {
|
||||
snapshot.ExpiresAt = clock.Add(-time.Second)
|
||||
}
|
||||
@@ -353,7 +169,7 @@ func TestGameClientBridgeReconciliationPrunesRetentionWithoutResettingStreamSequ
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stream := domain.GameClientBridgeSnapshotStream{ID: gameClientBridgeStreamID("server-1", "game.scum", "scum-client", "players", "current"), ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", LatestSequence: 4}
|
||||
stream := domain.GameClientBridgeSnapshotStream{ID: "stream-players-current", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "plugin-owned", Type: "players", StreamKey: "current", LatestSequence: 4}
|
||||
if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -50,17 +50,7 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
}
|
||||
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
|
||||
if !ok {
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||
}
|
||||
|
||||
leaseToken, err := randomToken()
|
||||
@@ -256,9 +246,6 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
|
||||
if err := svc.projectDependencyAndRunUpdateProgress(job, stamp); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
if err := svc.projectClientManagerLifecycleProgress(job, stamp); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, progress.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
@@ -278,6 +265,7 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
result = normalizeLegacyExecutionResultForJob(job, result)
|
||||
fingerprint := terminalFingerprint(result)
|
||||
if isTerminalJobState(job.State) {
|
||||
if job.TerminalFingerprint == fingerprint {
|
||||
@@ -346,18 +334,27 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
if err := svc.projectDependencyAndRunUpdateResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
func normalizeLegacyExecutionResultForJob(job domain.Job, result domain.RunJobResult) domain.RunJobResult {
|
||||
if result.ExecutionResult.Kind != "file" {
|
||||
return result
|
||||
}
|
||||
switch job.Capability {
|
||||
case domain.JobCapabilityFilesList:
|
||||
result.ExecutionResult.Kind = "file.list"
|
||||
case domain.JobCapabilityFilesRead:
|
||||
result.ExecutionResult.Kind = "file.read"
|
||||
case domain.JobCapabilityFilesWrite, domain.JobCapabilityConfigWrite:
|
||||
result.ExecutionResult.Kind = "file.write"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error {
|
||||
if definition := job.ExecutionInput.Deployment; definition != nil {
|
||||
receipt := result.ExecutionResult.DeploymentReceipt
|
||||
@@ -409,26 +406,6 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" {
|
||||
return validationError("Run self-update result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityClientManagerDeploy:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
|
||||
return validationError("client-manager deploy result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityClientManagerControl:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.control" {
|
||||
return validationError("client-manager control result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityClientManagerUpdate:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.updated" || result.State == domain.JobStateFailed && result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != "client-manager.rollback.restored" {
|
||||
return validationError("client-manager update result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityClientManagerRollback:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.rolled-back" {
|
||||
return validationError("client-manager rollback result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityClientManagerUninstall:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.uninstalled" {
|
||||
return validationError("client-manager uninstall result type is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -714,7 +691,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, FileTargetKey: job.ExecutionInput.FileTargetKey, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
FencingToken: 0,
|
||||
|
||||
@@ -196,6 +196,68 @@ func TestCoreServiceRunJobClaimSkipsServerFileCapabilityWithoutDeclaration(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceNormalizesLegacyFileListResultKind(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-file-list-legacy-kind",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Legacy File List Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesList)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-file-list-legacy-kind"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register run hello: %v", err)
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: "job-file-list-legacy-kind",
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Capability: domain.JobCapabilityFilesList,
|
||||
TargetKey: "server-root",
|
||||
IdempotencyKey: "idem-file-list-legacy-kind",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create file list job: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityFilesList}, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
|
||||
t.Fatalf("claim file list job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
completed, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
RunEndpointID: endpoint.ID,
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: domain.RunJobProgressReport{Percent: 100, Message: "listed"},
|
||||
Message: "listed",
|
||||
ExecutionResult: domain.JobExecutionResult{
|
||||
Kind: "file",
|
||||
Content: runFileListFixture("server-root", "", "SCUM"),
|
||||
Summary: "legacy bounded logical file listing",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete legacy file list job: %v", err)
|
||||
}
|
||||
if !completed.Accepted || completed.Job.State != domain.JobStateSucceeded {
|
||||
t.Fatalf("expected legacy file list result to be accepted, got %+v", completed)
|
||||
}
|
||||
stored, err := svc.GetJob(job.ID)
|
||||
if err != nil || stored.ExecutionResult.Kind != "file.list" {
|
||||
t.Fatalf("expected stored legacy file kind to be normalized, job=%+v err=%v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
|
||||
@@ -24,3 +24,28 @@ func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.Serve
|
||||
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||
return definition.Mode == ""
|
||||
}
|
||||
|
||||
// lifecycleDefaultProfileKey selects a plugin-declared lifecycle profile when
|
||||
// an older server record has no explicit deployment profile. Runtime bindings
|
||||
// remain optional logical transport overrides; they must not be required for
|
||||
// standard Run lifecycle, file, or log workflows.
|
||||
func lifecycleDefaultProfileKey(_ domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||
if profileKey != "" {
|
||||
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||
return profile.Key
|
||||
}
|
||||
return profileKey
|
||||
}
|
||||
return firstRuntimeLifecycleProfileKey(plugin)
|
||||
}
|
||||
|
||||
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||
return lifecycleDefaultProfileKey(domain.ServerInstance{}, plugin, profileKey)
|
||||
}
|
||||
|
||||
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user