feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
+2
View File
@@ -17,6 +17,8 @@ services:
# MySQL should store metadata/cursors/indexes, not one row per log line. # MySQL should store metadata/cursors/indexes, not one row per log line.
PLATFORM_LOG_BODY_BACKEND: file PLATFORM_LOG_BODY_BACKEND: file
PLATFORM_LOG_DIR: /data/platform/logs 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}
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
@@ -55,11 +55,11 @@
## 8. Real Distribution Build Repair ## 8. Real Distribution Build Repair
- [ ] 8.1 Replace synchronous synthetic run/client artifacts with queued `distribution.build` jobs, building distribution records, authenticated build-input retrieval, and terminal job projection. - [x] 8.1 Replace synchronous synthetic run/client artifacts with queued `distribution.build` jobs, building distribution records, authenticated build-input retrieval, and terminal job projection.
- [ ] 8.2 Implement the independent run worker build adapter for trusted run source and approved HTTPS client-manager repositories, including fixed Go builds, isolated workspaces, config packaging, checksums, and chunked artifact upload. - [x] 8.2 Implement the independent run worker build adapter for trusted run source and approved HTTPS client-manager repositories, including fixed Go builds, isolated workspaces, config packaging, checksums, and chunked artifact upload.
- [ ] 8.3 Drive the platform_web generation dialog from real job progress and terminal state instead of timer-completed stages. - [x] 8.3 Drive the platform_web generation dialog from real job progress and terminal state instead of timer-completed stages.
- [ ] 8.4 Add regression coverage proving generation queues a backend job, does not publish JSON plans as artifacts, publishes only uploaded build output, and reports actual progress/failure. - [x] 8.4 Add regression coverage proving generation queues a backend job, does not publish JSON plans as artifacts, publishes only uploaded build output, and reports actual progress/failure.
- [ ] 8.5 Run focused platform, run, frontend, OpenSpec, and structure verification and record the evidence below. - [x] 8.5 Run focused platform, run, frontend, OpenSpec, and structure verification and record the evidence below.
## Verification Evidence ## Verification Evidence
@@ -76,3 +76,17 @@
- `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh`: passed. - `LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance scripts/browser-acceptance.sh`: passed.
- Browser evidence file: `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json`. - Browser evidence file: `/private/tmp/browser-local-debug-acceptance/browser-acceptance/browser-acceptance-evidence.json`.
- Browser walkthrough evidence covered 首页、服务器管理、服务器管理 / 运行操作菜单、插件市场、用户管理、AI 提供商管理、服务器详情、服务器详情 / 插件控制, plus desktop/mobile checks for black mecha and magical-girl themes. - Browser walkthrough evidence covered 首页、服务器管理、服务器管理 / 运行操作菜单、插件市场、用户管理、AI 提供商管理、服务器详情、服务器详情 / 插件控制, plus desktop/mobile checks for black mecha and magical-girl themes.
### Real Distribution Build Repair Evidence (2026-07-17)
- `cd platform && go test ./service ./validator ./api`: passed.
- `cd run && go test ./runtime ./protocol ./api`: passed; the runtime suite compiled a real run executable from an isolated source copy and uploaded the archive through chunked artifact calls.
- `cd platform_web && npm test -- --run components/RuntimeTaskProgress.test.ts pages/ConsolePages.test.tsx`: passed, 2 files / 13 tests.
- `cd platform && go test ./...`: passed across all platform packages.
- `cd run && go test ./...`: passed across all independent run packages.
- `cd platform_web && npm test`: passed, 15 files / 76 tests.
- `cd platform_web && npm run typecheck`: passed.
- `cd platform_web && npm run build`: passed, Vite production build completed.
- `openspec validate add-run-distribution-and-client-managers --strict`: passed.
- `scripts/check-structure.sh`: passed.
- Regression evidence covers queued `distribution.build` creation with no synthetic artifact, authenticated leased build-input retrieval, rejection of premature success before artifact upload, successful retry after real artifact publication, retained downloadable chunk payloads, isolated trusted run source copies, rejection of credential-bearing/unpinned client repositories, and frontend projection of real running/succeeded/failed job states.
@@ -0,0 +1,35 @@
# Design
## Session records
`AuthSessionRecord` stores only a SHA-256 token hash, user ID, issued/expiry timestamps, revoked timestamp, and rotation generation. The bearer token is returned once at login/rotation and is never stored in a snapshot. `GetCurrentUser` hashes the presented token and loads the record from the repository, rejecting missing, revoked, expired, or disabled-user sessions. The default TTL is bounded (8 hours); rotation revokes the prior generation before creating a new record. FileStore and MySQLStore snapshots load these records before serving requests.
Strict production routers deliver the platform session in an `HttpOnly`, `SameSite=Strict` cookie and omit the token from JSON, so platform_web JavaScript does not persist new raw tokens. Explicit CLI/local tooling may request a bearer response with `X-Auth-Token-Response: bearer`; the explicitly named `NewTestRouterWithCore` compatibility constructor retains the bearer response contract for existing in-process tests. All normal router constructors enforce authorization.
Run control state follows the same shape: a Run session has a bounded TTL, status, generation, capability fingerprint, and last-seen timestamp. Heartbeat/claim/ack/progress/result/cancel/reconcile/log/artifact operations must validate the current session and reject expired or revoked state.
## Signed Run envelope
HTTP Run channel requests carry `X-Run-Endpoint`, `X-Run-Timestamp`, `X-Run-Nonce`, and `X-Run-Signature`. The signature is HMAC-SHA256 over method, path, timestamp, nonce, and SHA-256 request body using the established Run session token as the channel key. Timestamps are accepted only inside a five-minute clock-skew window and each nonce is accepted once per Run session. The body is buffered and restored before JSON decoding. Legacy in-process service calls remain available for existing tests, but HTTP channel handlers reject missing/invalid signatures after session establishment. Signature errors are safe 401/403 failures and never echo token material.
## Authorization matrix
- Public: health, login, registration, and read-only marketplace/plugin discovery that contains no secret or host detail.
- Platform admin: user administration, AI provider metadata, plugin installation/registration/state, Run endpoint administration, platform metrics, audit inspection, and global job/artifact/log metadata.
- Authenticated server owner or assigned server administrator: server list/detail, runtime binding review, lifecycle, config diff/approval, logs, artifacts, distributions, dependency and client-manager actions for servers visible to that user.
- Owner or platform admin only: runtime binding writes, key reset, server administrator membership changes, destructive/archive actions.
- Run service identity: channelized control/jobs/logs/artifacts for its own endpoint and only resources addressed by a validated job/transfer/session; never browser bearer sessions.
Services repeat ownership and role checks even when a route already checked them. Cross-owner reads/writes return `forbidden`; missing/invalid/expired credentials return `unauthorized`.
## Secret boundary
Secret-bearing writes accept only controlled `secret://` references or opaque logical refs already declared by a plugin. Stored records keep references, encrypted component-key material, status, generation, and fingerprints; raw values are never returned. DTOs expose only `configured`, `secret`, `presence`, and safe fingerprint/status fields. Snapshot tests scan serialized JSON and browser responses for secret literals, paths, sockets, and credentials.
## Failure and migration behavior
Older snapshots decode absent auth/session/secret arrays as empty. Existing users remain valid but must log in again after upgrade if no session record exists. Existing Run endpoints without a persisted session require a fresh signed hello. Expired/revoked records remain durable for audit and can be pruned in a later change. No rollback step writes raw secret data.
## Deferred risks
The controlled secret reference is not a production vault and encrypted component-key material still depends on the configured platform protection boundary. KMS/HSM integration, key wrapping, and multi-resource transactions are explicitly deferred and must not be represented as complete by this change.
@@ -0,0 +1,33 @@
# Harden Platform Authentication, Authorization, and Secret Persistence
## Why
Platform account sessions and Run control sessions currently live only in process memory, so a restart invalidates valid clients and leaves revocation/expiry state implicit. Several management routes also rely on the caller reaching a UI path rather than enforcing an authenticated role or resource-ownership boundary at the API/service boundary. Existing component-key and distribution metadata is not included in the durable file/MySQL snapshot, while secret-bearing values must remain platform-owned and redacted.
## What Changes
- Persist hashed platform sessions and Run control session state with explicit issued/expiry/revoked timestamps and safe rotation.
- Require authenticated sessions for sensitive management APIs and enforce platform-admin, server-owner/administrator, and Run-service boundaries in handlers and services.
- Add a signed, timestamped Run request envelope for control/job/log/artifact channel requests where the HTTP boundary can validate a trusted Run session and reject stale/replayed messages.
- Persist existing encrypted component-key and distribution metadata in FileStore/MySQLStore snapshots, plus controlled secret metadata references and presence/fingerprint projections.
- Ensure auth failures are stable 401/403 API errors and the web client clears invalid sessions without rendering token, key, path, socket, or secret literals.
- Add regression coverage for login/reload, expiry/revocation/rotation, signed Run requests, cross-owner/role rejection, replay/clock failures, snapshot recovery, and non-disclosure.
## Goals / Non-Goals
**Goals:**
- Make session and authorization decisions durable and independently enforceable from the UI.
- Keep raw credentials, host paths, direct sockets, and provider keys out of DTOs, logs, snapshots, and browser state.
- Preserve the independent `run` repository boundary and channel priorities.
**Non-Goals:**
- A production KMS/vault, encrypted secret-value storage, durable scheduling, process supervision, logs/artifacts backends, dependency installation, self-update, client-manager lifecycle, or production scaling.
- Re-adding Run source code to this repository or claiming the later roadmap is complete.
## Impact
- `platform/`: auth/session domain, repositories, snapshots, signed Run request validation, route authorization, safe secret metadata.
- `platform_web/`: API error/session handling and safe auth capability projections.
- `plugins/`: no raw credential or direct channel access; manifest/SDK contracts remain unchanged except for regression fixtures if needed.
@@ -0,0 +1,71 @@
# Platform Authentication, Authorization, and Secret Persistence
## ADDED Requirements
### Requirement: Platform sessions are durable and bounded
The platform SHALL persist only hashed bearer-session records with user ownership, issued time, expiry time, revocation time, and rotation generation, and SHALL reject missing, expired, revoked, or disabled-user sessions.
#### Scenario: Session survives restart
- **WHEN** a user logs in, the platform store is closed and reopened, and the same bearer token is presented before expiry
- **THEN** the platform restores the session record and authenticates the user without storing the raw token
#### Scenario: Expired or revoked session is rejected
- **WHEN** an expired or revoked bearer token is presented
- **THEN** the API returns 401 and performs no protected read or write
#### Scenario: Session rotation revokes the old generation
- **WHEN** an authenticated user rotates a session
- **THEN** a new token is issued, the previous generation is revoked durably, and the previous token is rejected
#### Scenario: Browser session token is not script-readable
- **WHEN** a user logs in through the strict production router
- **THEN** the platform sets an HttpOnly SameSite session cookie and omits the raw token from the JSON response
### Requirement: Run requests use a trusted bounded channel
Run control, job, log, and artifact channel requests SHALL validate the current endpoint session and, at the HTTP boundary, a timestamped HMAC signature with a five-minute clock-skew limit and single-use nonce.
#### Scenario: Valid signed request
- **WHEN** a request is signed by the current Run session with an accepted timestamp and unused nonce
- **THEN** the request is processed for that endpoint and the nonce is recorded as used
#### Scenario: Invalid, stale, or replayed request
- **WHEN** the signature is invalid, the timestamp is outside the skew window, or the nonce was already used
- **THEN** the request is rejected with a safe authentication error and no state mutation occurs
### Requirement: Sensitive APIs enforce role and resource ownership
Sensitive user, provider, plugin, server, distribution, job, runtime-binding, audit, and channel metadata APIs SHALL enforce platform-admin, server-owner/administrator, or Run-service identity at the API and service boundaries.
#### Scenario: Cross-owner access
- **WHEN** an authenticated non-owner requests another owner's server binding, job, config, artifact, or action
- **THEN** the platform returns 403 and leaves the resource unchanged
#### Scenario: UI bypass
- **WHEN** a caller invokes a sensitive route directly without the required role or session
- **THEN** the platform rejects the call regardless of UI state or request shape
### Requirement: Core auth and secret metadata are durable and redacted
FileStore and MySQLStore SHALL persist auth sessions, Run session state, encrypted component-key metadata, distributions, and controlled secret references through the repository snapshot contract; raw tokens, keys, credentials, paths, sockets, and provider secret values MUST NOT appear in snapshots, logs, DTOs, or browser responses.
#### Scenario: Snapshot reload preserves safe metadata
- **WHEN** the store is reopened after creating a session, Run identity, component key, or secret reference
- **THEN** safe status/generation/presence metadata remains available and raw values remain absent
#### Scenario: Secret presence projection
- **WHEN** a secret reference is configured
- **THEN** API and web responses expose only presence/configured/secret flags and safe fingerprints, never the reference value or storage location
### Requirement: Web clients handle auth failures safely
The platform web client SHALL treat 401 as a session reset/re-login condition and 403 as a capability/ownership denial, without persisting or rendering raw tokens, credentials, paths, sockets, or secret references.
#### Scenario: API session expires in the console
- **WHEN** an API call returns 401
- **THEN** the client clears the bearer token and exposes a safe re-authentication state
#### Scenario: API authorization is denied
- **WHEN** an API call returns 403
- **THEN** the client reports a safe access-denied error without including secret or infrastructure details
## Deferred Requirements
- Production KMS/HSM/vault encryption and secret-value rotation are deferred.
- Durable scheduling, process supervision, logs/artifacts backends, dependency installation, self-update, client-manager lifecycle, and production scaling are outside this change.
@@ -0,0 +1,22 @@
## 1. Planning and contracts
- [x] 1.1 Add typed durable auth-session and Run-session contracts, repository interfaces, validation rules, and safe DTO projections.
- [x] 1.2 Document the auth/authorization matrix, signed envelope, replay/clock constraints, secret-ref boundary, and deferred production risks.
## 2. Durable authentication and Run trust
- [x] 2.1 Persist hashed user sessions with expiry, revocation, rotation, and reload support in MemoryStore/FileStore/MySQLStore.
- [x] 2.2 Persist Run session state and enforce bounded lifecycle plus signed timestamp/nonce validation at HTTP channel boundaries.
- [x] 2.3 Add login/session-rotation/revocation routes and safe 401/403 error behavior.
## 3. Authorization and secret boundary
- [x] 3.1 Enforce platform-admin, owner/administrator, and Run-service authorization on sensitive routes and repeat checks in services.
- [x] 3.2 Persist component-key/distribution/secret metadata through all durable snapshots without raw secret disclosure.
- [x] 3.3 Add API and service regressions for cross-owner access, expired/revoked credentials, replay, and non-disclosure.
## 4. Web and verification
- [x] 4.1 Update platform_web API/session handling for 401/403 and capability-safe projections with no secret literals.
- [x] 4.2 Add frontend regressions for session reset/denied access and secret/path/socket non-disclosure.
- [x] 4.3 Run platform, plugin, web, OpenSpec strict validation, structure checks, and risk-relevant independent Run tests; record evidence and leave later roadmap work explicitly deferred.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,84 @@
## Context
Task 05 made lifecycle/config/file jobs real and Task 06 made logs, artifacts, metrics, backups, and remote adapters durable. Distribution APIs already create `dependencies.check`, `dependencies.install`, and `run.self-update` jobs, but Run currently returns immediate synthetic success. Platform also accepts any available same-owner-visible artifact for an update and has no Run-only artifact read route, immutable dependency-plan approval, terminal dependency projection, or update activation journal.
The implementation spans the main repository and the independent `run/` repository. Platform remains authoritative for user ownership/admin scope, installed plugin/version, server/runtime binding, selected endpoint, Run session/signature, attempt/lease/cancel fencing, artifact ownership, immutable plan digest, and audit. Run owns machine-local resolution, typed adapter execution, staging, replacement, health confirmation, and rollback. Plugins declare safe logical plans; platform_web receives only safe projections.
## Goals / Non-Goals
**Goals:**
- Execute declared dependency probes and install plans through fixed, testable adapters with bounded time, output, retries, and cancellation.
- Make the exact platform/architecture-specific plan reviewable and bind installation approval to its digest.
- Persist dependency status and update phases across Platform and Run restarts.
- Download only an approved same-server Run distribution through a signed, active-attempt-only, resumable contract.
- Verify archive and binary bounds/checksums, preserve the current package configuration, stage durably, activate only after the terminal result is accepted, confirm startup health, and roll back on failure.
- Keep heartbeat, job ack/result/cancel polling, logs, and artifact upload independent from slow dependency/update work.
**Non-Goals:**
- Arbitrary shell/script execution, user-supplied command vectors, generic root package management, unverified HTTP downloads, or undeclared host/credential access.
- Production code-signing/KMS, release rings, fleet rollout, centralized binary mirrors, dependency solving, Run service-manager installation, client-manager lifecycle, plugin lifecycle, production scaling/alerts, or real AI-provider integration.
## Decisions
### Decision 1: Platform snapshots declared inputs and approves an immutable digest
Platform derives a safe dependency catalog from the installed plugin version, selected runtime profile, endpoint OS/architecture, and declared probes/plans. A canonical digest covers the declaration and non-secret logical binding generation. An install request must include the digest returned by the catalog. Dispatch re-resolves the declaration and rejects changed plugin versions, plan steps, target platform, bindings, endpoint, or digest before creating the job.
Alternative considered: accept only a plan key and resolve it at execution time. Rejected because an operator could approve one plan and execute a later plugin revision.
### Decision 2: Private execution inputs use active fenced Run routes
Dependency declarations/resolved target values and Run update manifests are not placed in browser-visible job DTOs. Run retrieves them through signed `/api/v1/run/jobs/dependency-input`, `/api/v1/run/jobs/update-input`, and `/api/v1/run/jobs/update-chunk` routes carrying endpoint, session, job, attempt, and lease. Platform uses the existing fenced-job check and requires an active matching capability. Update chunks are bounded and range-addressed; no browser download token or raw storage path is returned.
Alternative considered: embed all data in `Job.ExecutionInput.Content`. Rejected because it weakens type separation and increases the chance of private binding or package data entering general job projections.
### Decision 3: Dependency work uses a closed adapter registry
Run maps probe kinds and install step types to fixed implementations. Package steps map a whitelisted manager to fixed argument builders and validate package/version tokens. Verified downloads require HTTPS, a declared SHA-256 checksum, a size limit, and a scoped destination. SteamCMD uses a fixed executable/argument shape. Manual steps return a safe blocked result and never claim installation. No adapter invokes a shell, evaluates manifest text, accepts environment overrides, or returns command output/paths.
The executor has injected command/download/filesystem interfaces for deterministic tests; production implementations use `exec.CommandContext`, bounded HTTPS, owner-only workspaces, and atomic files.
Alternative considered: translate declarations into shell scripts. Rejected because shell parsing defeats the declared capability boundary.
### Decision 4: Dependency state is projected from terminal evidence
Run returns a typed execution result containing only declaration key, present/missing/installed/failed classification, bounded version evidence, completed step count, and plan digest. Platform verifies that evidence against the job snapshot before updating `DependencyStatus`. Attempts and local journals are idempotent; retry/cancel/stale results remain governed by the existing scheduler. Audit summaries never include resolved paths, commands, package-manager output, or credentials.
### Decision 5: Self-update is a durable two-process transaction
Run streams the approved distribution archive into an owner-only transaction directory, persists offset/hash metadata, verifies the final artifact checksum, safely extracts exactly one expected Run binary, and records a staged manifest. Archive traversal, links, devices, duplicate executables, excess entries, oversized bodies, target mismatch, and bundled configuration replacement are rejected.
After Platform accepts the successful staged job result and the Run journal has persisted the acknowledgement, the worker launches the staged binary in helper mode and exits. The helper waits for the old PID, backs up the current executable, copies the staged binary through an atomic temporary target, starts the new executable with helper-only environment removed, and waits for a startup-health marker. The new worker writes that marker and sends a signed update-health report only after registration and job reconciliation succeed; Platform keeps the safe phase at `restart-requested/activating` until that report matches the terminal update job, endpoint, attempt, lease proof, target release, and current session. Failure restores the backup and restarts the previous binary. The package's existing `config.json` remains untouched.
Alternative considered: replace the executable before reporting the job. Rejected because Platform could retain a running lease with no terminal result. Alternative considered: report success after merely staging. Rejected because the update record would misrepresent activation; the safe projection distinguishes `staged/restart-requested`, `activating`, `succeeded`, `rolled-back`, and `failed` phases.
### Decision 6: Recovery is driven by journals, not process memory
Dependency executions store completed step indexes and immutable digests under the scoped workspace. Update transactions store artifact offset, expected checksum, staged binary checksum, current/backup logical locations, phase, attempt, and timestamps. Startup recovery removes invalid partial data, resumes eligible downloads, confirms a healthy activated transaction, or rolls back an interrupted activation. Attempt/lease values are used for fencing but never exposed in safe status or logs.
### Decision 7: Endpoint target identity and channel priority remain explicit
Run endpoint records persist OS/architecture from hello so Platform can reject cross-target distributions and plans. Downloading dependencies or update chunks occurs inside the claimed job goroutine; heartbeat and durable log/artifact upload loops remain separate. Progress/cancel polling uses bounded contexts. Tests block download/adapters while asserting heartbeat, ack/result, and log upload deadlines.
## Risks / Trade-offs
- [Package managers vary across distributions and may require privilege] → Validate the endpoint OS, use manager-specific fixed arguments, surface a safe permission failure, and never auto-escalate through sudo/shell.
- [A process can crash between staged result and helper activation] → Persist the post-ack activation request and recover it at startup; Platform distinguishes staging from confirmed version/health.
- [Windows executable replacement differs from Unix rename behavior] → Helper copies from the staged executable after the parent exits and uses backup/temporary targets instead of renaming a running binary.
- [A newly started binary can launch but fail registration] → New Run writes health only after successful registration/reconciliation; helper times out and restores the previous executable.
- [Old records lack endpoint OS/architecture or plan digests] → Existing endpoints re-register before real actions become available; legacy queued placeholder jobs are not retroactively executed.
- [Large update archives can consume disk/network] → Enforce artifact/archive/binary limits, bounded chunks, resumable offsets, owner-only roots, and cleanup after terminal retention.
## Migration Plan
1. Add backward-compatible endpoint target fields, dependency/update records, DTOs, protocols, repositories, and private routes.
2. Require endpoint re-registration to advertise a supported OS/architecture before enabling dependency install or self-update.
3. Publish safe dependency catalog/status and update phase projections; existing generic job views remain compatible.
4. Enable real Run capabilities only when the typed executors and journals initialize successfully.
5. On rollback, stop advertising the real capabilities and leave private journals/artifacts for a compatible binary to recover; do not delete or reinterpret prior Platform records.
## Open Questions
- Production signing policy and rollout rings remain a future change; this task enforces artifact ownership, target match, content checksum, and optional signature metadata without claiming a production PKI.
@@ -0,0 +1,32 @@
## Why
Platform can currently queue dependency and Run update jobs, but Run returns synthetic success without executing a declared install step or downloading, verifying, staging, activating, and recovering an update. Operators therefore see completion for work that did not happen, and the existing job/artifact security boundaries are not yet sufficient for real machine mutation.
## What Changes
- Resolve plugin-declared dependency probes and install plans through Platform ownership, installed-plugin, runtime-profile, binding, endpoint, platform, session, attempt, and lease checks.
- Expose a safe, reviewable dependency catalog and require approval of the exact immutable plan digest before dispatching an install.
- Execute only typed package, verified-download, and SteamCMD steps through fixed adapters; reject arbitrary shell, scripts, unsafe package arguments, unapproved downloads, undeclared targets, stale plans, and unsupported operating systems.
- Persist dependency execution status/evidence and project terminal job results into bounded, redacted Platform records and UI status.
- Add a Run-only, fenced, resumable artifact download contract for approved same-server Run distributions.
- Download, checksum-verify, safely extract, stage, and durably journal Run updates; activate them through a post-result helper, verify startup health, and roll back on activation failure.
- Persist Run update phases and audit outcomes without exposing host paths, Run/session/lease tokens, secret refs, credentials, PIDs, sockets, or private plan bindings.
- Preserve control/job/log/artifact channel isolation so dependency downloads and update transfer/activation do not delay heartbeat, job acknowledgement/result, cancellation polling, or log upload.
## Capabilities
### New Capabilities
- `durable-dependency-execution`: reviewable declared dependency plans, fenced Run input, typed probes/install adapters, persistence, recovery, cancellation, and safe projections.
- `transactional-run-self-update`: approved artifact download, resumable verification, durable staging, post-result activation, startup health confirmation, rollback, and audit semantics.
### Modified Capabilities
- `run-distribution-and-client-managers`: dependency and self-update jobs now perform real bounded machine work instead of success-only hooks.
- `artifact-transfer-channel`: authenticated Run jobs can read approved distribution artifacts in bounded resumable chunks without using browser download sessions.
## Impact
- Affects `plugins/` dependency declaration validation/SDK examples, `platform/` domain/DTO/model/repository/service/protocol/validator/API layers, projection-only `platform_web/` dependency/update status, and the independent `run/` protocol/runtime/config/shared layers.
- Adds no arbitrary shell capability and no raw host path, credential, socket, token, lease, session hash, or secret projection to plugins or platform_web.
- Does not implement client-manager lifecycle, dependency installation outside declared adapters, Run distribution signing infrastructure/KMS, production rollout rings/fleet orchestration, plugin lifecycle, production scaling/alerts, or real AI-provider integration.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Active Run update jobs can read approved artifact ranges
The artifact channel SHALL provide a signed, bounded, resumable read contract exclusively for an active fenced `run.self-update` attempt whose artifact is an available same-server target-matched Run distribution.
#### Scenario: Run reads the next update range
- **WHEN** Run presents the selected endpoint/session/job/attempt/lease and a valid offset and length
- **THEN** Platform MUST return only that bounded artifact range plus artifact ID, offset, total size, checksum, and completion metadata
#### Scenario: Run requests unrelated artifact data
- **WHEN** the job is inactive, the artifact/distribution/server/endpoint/target differs, or the range exceeds bounds
- **THEN** Platform MUST reject the request without returning bytes, paths, credentials, browser download sessions, secret refs, or cross-owner metadata
#### Scenario: Update transfer is slow
- **WHEN** an update range read or network response is blocked
- **THEN** control, job ack/result/cancel, log ingest, and independent artifact upload routes MUST continue without waiting on the read
@@ -0,0 +1,68 @@
## ADDED Requirements
### Requirement: Dependency plans are declared and reviewable
Platform SHALL derive a safe dependency catalog from the installed plugin version, selected runtime profile, complete binding, and Run target, and SHALL require approval of the exact plan digest before installation.
#### Scenario: Operator reviews an install plan
- **WHEN** an authorized owner or platform administrator queries dependency actions for a server
- **THEN** Platform MUST return declared probe keys, plan titles, target OS/architecture, typed step summaries, current safe status, and a deterministic plan digest without host paths, commands, credentials, secret refs, sockets, tokens, leases, sessions, hashes used for fencing, or PIDs
#### Scenario: Approved plan changes before dispatch
- **WHEN** the plugin version, runtime profile, target, binding generation, plan steps, or digest no longer matches the reviewed plan
- **THEN** Platform MUST reject installation and record a safe denied audit event before creating a job
#### Scenario: Caller crosses server ownership
- **WHEN** a non-owner without server-admin or platform-admin scope requests a catalog, check, or install
- **THEN** Platform MUST return the existing unauthorized/forbidden semantics and MUST NOT reveal whether private bindings or plans exist
### Requirement: Dependency execution input is fenced and private
Run SHALL receive dependency declarations and resolved target values only through a signed Platform route scoped to the active endpoint, session, job, attempt, and lease.
#### Scenario: Active Run loads dependency input
- **WHEN** the selected Run requests input for its active dependency attempt
- **THEN** Platform MUST verify endpoint ownership, session/signature, job capability/state, attempt/lease, server/plugin/profile/target, immutable digest, and cancellation state before returning the bounded typed input
#### Scenario: Stale or cross-endpoint Run requests input
- **WHEN** the endpoint, session, attempt, lease, server, plugin version, profile, or capability does not match the active job
- **THEN** Platform MUST reject the request without returning declarations, bindings, host targets, or plan data
### Requirement: Dependency probes and installs use closed typed adapters
Run SHALL execute only supported declared probe kinds and install step types through fixed adapters and SHALL never evaluate arbitrary shell, script text, environment overrides, or caller-supplied command vectors.
#### Scenario: Declared probe executes
- **WHEN** a supported command-version, Java, Docker, package, service, Steam app, or file probe is requested for the current Run platform
- **THEN** Run MUST resolve only the approved target, enforce timeout/output bounds, and return a safe present/missing/version classification
#### Scenario: Typed package plan executes
- **WHEN** an approved package step names a supported manager, safe package token, optional safe version, and matching platform
- **THEN** Run MUST use the fixed manager adapter, respect cancellation and timeout, persist step completion idempotently, and never invoke a shell or unapproved privilege escalation
#### Scenario: Verified download executes
- **WHEN** an approved verified-download step uses HTTPS, an allowed host, a SHA-256 checksum, a bounded size, and a scoped logical destination
- **THEN** Run MUST stream to an owner-only temporary file, verify checksum before atomic publication, and remove invalid partial data
#### Scenario: Unsafe or unsupported step is requested
- **WHEN** a declaration contains shell syntax, an unsafe package/version token, HTTP or credential-bearing URL, missing checksum, undeclared target, unsupported platform/manager/type, symlink escape, or manual-only step
- **THEN** validation or Run MUST reject it without machine mutation and return a bounded safe failure
### Requirement: Dependency execution is durable and auditable
Platform and Run SHALL make dependency execution restart-safe, idempotent, cancellable, retry-bounded, and auditable.
#### Scenario: Run restarts during a multi-step install
- **WHEN** Run recovers an active attempt with a matching immutable digest
- **THEN** it MUST resume after the last durably completed idempotent step and MUST NOT repeat a completed step or accept a stale attempt
#### Scenario: Cancellation arrives during a blocked adapter
- **WHEN** Platform records cancellation for the active dependency job
- **THEN** Run MUST cancel the adapter context, stop before the next step, preserve recoverable evidence, and report a fenced cancelled result
#### Scenario: Terminal dependency evidence is accepted
- **WHEN** Platform accepts a current terminal probe or install result
- **THEN** it MUST update the durable dependency status and audit actor, server, plugin, probe/plan, attempt outcome, and safe summary without private execution details
### Requirement: Dependency work preserves channel deadlines
Slow package managers and downloads SHALL NOT block Run control heartbeat, job acknowledgement/result, cancellation polling, log upload, or artifact channel progress.
#### Scenario: Dependency adapter is blocked
- **WHEN** a dependency command or download remains blocked beyond a heartbeat interval
- **THEN** heartbeat, log acknowledgement, cancellation polling, and unrelated job-channel requests MUST continue through independent bounded operations
@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Dependency checks and installs are typed
Run SHALL check dependencies through plugin-declared probes and SHALL install missing dependencies only through approved, typed, reviewable, immutable plans executed by fixed adapters.
#### Scenario: Dependency check reports missing runtime
- **WHEN** Run evaluates a declared probe for a required runtime, service, package, toolchain, Steam app, Java runtime, Docker runtime, or file and finds it missing
- **THEN** Platform MUST persist and show the safe dependency status and a reviewable platform-matched install plan when the installed plugin declares one
#### Scenario: Dependency install is approved
- **WHEN** an authorized operator approves the current immutable plan digest
- **THEN** Platform MUST queue a fenced job and Run MUST execute only the typed package, verified-download, or SteamCMD steps, persist resumable evidence, and reject arbitrary shell or stale plan input
#### Scenario: Dependency result is synthetic
- **WHEN** Run has not executed and verified the declared probe or install steps
- **THEN** it MUST NOT report the dependency present, installed, or successfully completed
### Requirement: Online run endpoints self-update through platform jobs
The platform SHALL update online Run endpoints through a bounded job that reads an approved same-server target-matched distribution, and Run SHALL durably download, verify, stage, activate, health-check, and roll back the update without receiving raw shell commands.
#### Scenario: Online Run accepts update
- **WHEN** the assigned endpoint is online, advertises real self-update capability, and the artifact matches its server and OS/architecture
- **THEN** Platform MUST queue a fenced update job and Run MUST download by bounded ranges, verify checksum, stage safely, report the result, and activate only after Platform accepts that result
#### Scenario: Update verification fails
- **WHEN** Run cannot verify or stage the artifact
- **THEN** Run MUST keep the current executable and configuration, report a bounded failure, preserve heartbeat/status, and never launch the update helper
#### Scenario: Updated Run fails health confirmation
- **WHEN** the replacement cannot start or authenticate/reconcile with the same identity before timeout
- **THEN** Run MUST restore and restart the previous executable and Platform MUST project a rolled-back/failed outcome rather than success
@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Run updates use approved target-matched distributions
Platform SHALL dispatch self-update only for an available Run distribution owned by the same server, built for the registered endpoint OS/architecture, and matching the recorded artifact checksum.
#### Scenario: Authorized update is queued
- **WHEN** an authorized owner or platform administrator selects an available same-server distribution for the online endpoint
- **THEN** Platform MUST bind the update record and job to the distribution, artifact, checksum, target, endpoint, and idempotency key and record a queued audit event
#### Scenario: Artifact is cross-owner or cross-target
- **WHEN** the artifact belongs to another server/job, is not an available Run distribution, has a different checksum, or targets another OS/architecture
- **THEN** Platform MUST reject the update before job creation without revealing artifact contents or private ownership metadata
### Requirement: Update artifact reads are resumable and fenced
Run SHALL download update artifacts through a signed active-attempt-only chunk contract with bounded offsets, lengths, total size, and checksum metadata.
#### Scenario: Download resumes after interruption
- **WHEN** Run restarts or a chunk request fails after a durable offset was recorded
- **THEN** Run MUST request the next bounded range, verify every returned offset/length and the final checksum, and MUST NOT redownload already verified bytes
#### Scenario: Stale attempt requests a chunk
- **WHEN** a cancelled, expired, wrong-endpoint, wrong-session, wrong-lease, or superseded attempt requests update metadata or bytes
- **THEN** Platform MUST reject it and MUST NOT return artifact bytes, storage paths, browser tokens, secret refs, or fencing hashes
### Requirement: Run stages updates safely
Run SHALL safely validate and stage exactly the expected Run executable from the approved distribution while preserving the installed package configuration.
#### Scenario: Valid package is staged
- **WHEN** all artifact bytes and the archive checksum are verified
- **THEN** Run MUST reject archive traversal/links/devices/duplicates, enforce entry and binary size limits, extract the target-matched executable into an owner-only transaction directory, verify its checksum, fsync the journal, and leave the current executable/configuration unchanged
#### Scenario: Package verification fails
- **WHEN** checksum, target, format, entry bounds, executable identity, or extraction validation fails
- **THEN** Run MUST keep the current executable, remove or quarantine invalid partial data, report a bounded failure, and remain able to heartbeat and accept cancellation
### Requirement: Activation occurs only after fenced result acceptance
Run SHALL activate a staged update only after Platform accepts the terminal staged result for the current attempt and the local result acknowledgement is durable.
#### Scenario: Staging result is rejected
- **WHEN** Platform rejects the result because the session, attempt, lease, cancellation state, or terminal fingerprint is stale
- **THEN** Run MUST NOT launch the update helper or replace the executable
#### Scenario: Staging result is accepted
- **WHEN** Platform accepts the current staged result
- **THEN** Run MUST persist the post-ack activation request, launch the staged helper, stop the old worker without dropping the accepted result, and project the update as restart-requested/activating until health is confirmed
### Requirement: Activation is health-checked and rollback-safe
The update helper SHALL back up, replace, launch, confirm, and finalize an update transaction, and SHALL restore the previous executable if activation fails.
#### Scenario: New Run becomes healthy
- **WHEN** the new executable starts, authenticates, registers the same endpoint/server identity, reconciles jobs, and writes the transaction health marker before timeout
- **THEN** it MUST submit a signed current-session health report fenced to the terminal update job/attempt/lease, the helper MUST mark the transaction succeeded, retain bounded rollback evidence, and Platform MUST confirm the endpoint's new release/checksum in the safe update projection only after accepting that report
#### Scenario: Replacement or health confirmation fails
- **WHEN** copy/rename/start fails, the new process exits, identity differs, or health is not confirmed before timeout
- **THEN** the helper MUST atomically restore the backup where possible, restart the previous executable, mark rolled-back/failed recovery state, and never claim update success
#### Scenario: Run restarts with an interrupted transaction
- **WHEN** startup finds a durable downloading, staged, activating, or rollback transaction
- **THEN** it MUST resume the safe phase, clean invalid state, or roll back deterministically without applying a different artifact or stale attempt
### Requirement: Update status and audit projections are safe
Platform_web and plugins SHALL receive only bounded update identity, artifact checksum, target, phase, progress, timestamps, rollback outcome, endpoint version/release, and safe audit summaries.
#### Scenario: Update status is queried
- **WHEN** an authorized user opens server runtime status
- **THEN** the response MUST omit host/executable/staging/backup paths, raw artifact bodies, credentials, Run tokens, session/lease values or hashes, secret refs, helper PIDs, sockets, and private package configuration
### Requirement: Update transfer and activation preserve channel deadlines
Slow update downloads and helper preparation SHALL NOT block control heartbeat, job acknowledgement/result, cancellation polling, logs, or unrelated artifact uploads.
#### Scenario: Update download is slow
- **WHEN** update chunk transfer is delayed or the artifact is large
- **THEN** heartbeat, active job lease renewal, cancellation polling, log upload, and unrelated result reporting MUST continue through separate bounded loops
@@ -0,0 +1,45 @@
## 1. Contracts And Persistence
- [x] 1.1 Add Platform domain, DTO, model, repository, protocol, validator, and safe projection contracts for dependency catalogs/snapshots/results and Run update manifests/chunks/phases.
- [x] 1.2 Persist Run endpoint OS/architecture, dependency execution evidence, plan digests, and update transaction/rollback status through memory, file, and MySQL snapshot repositories.
- [x] 1.3 Add independent Run protocol/runtime/config/shared types for private dependency input, resumable update reads, typed evidence, and durable update journals without importing main-repository source.
## 2. Platform Authorization And Orchestration
- [x] 2.1 Implement authorized dependency catalog/status queries with deterministic safe plan digests and exact installed-plugin/profile/target/binding projections.
- [x] 2.2 Require current plan-digest approval for installs and dispatch only declared platform-matched probes/plans to the selected online endpoint.
- [x] 2.3 Implement signed, session/attempt/lease/cancel-fenced Run dependency-input, update-input, and bounded update-chunk routes with same-server distribution and target checks.
- [x] 2.4 Project accepted terminal dependency/update evidence into durable status/phase/audit records while rejecting stale, cross-owner, cross-endpoint, cross-target, or conflicting results.
## 3. Real Dependency Execution
- [x] 3.1 Implement Run probe adapters for supported declared command/version, Java/Docker/package/service/Steam/file checks with bounded redacted evidence.
- [x] 3.2 Implement fixed package-manager, verified HTTPS download, and SteamCMD install adapters with no shell, safe tokens/hosts/checksums/paths, timeouts, and cancellation.
- [x] 3.3 Add durable dependency journals, step idempotency, retry/restart recovery, digest fencing, safe failures for manual/unsupported steps, and tests.
## 4. Transactional Run Self-Update
- [x] 4.1 Implement bounded resumable update download with durable offsets, per-range/final checksum verification, cancellation, and restart recovery.
- [x] 4.2 Implement safe zip/tar extraction of the expected target binary, archive/binary limits, owner-only staging, configuration preservation, and durable transaction manifests.
- [x] 4.3 Implement post-result-ack helper activation, parent exit coordination, backup/atomic replacement, helper-environment cleanup, startup identity/health confirmation, rollback, and interrupted-transaction recovery.
- [x] 4.4 Add Run self-update tests for wrong artifact/target/checksum, partial resume, stale fencing, result rejection, activation success, health timeout rollback, and journal restart.
## 5. Plugins And Platform Web
- [x] 5.1 Tighten plugin manifest/SDK dependency declarations and example plans for fixed adapters, approved download hosts/checksums, step bounds, and unsafe shell/URL/token rejection.
- [x] 5.2 Add platform_web safe dependency catalog/status and Run update phase/checksum/rollback/audit views using existing black-mecha/magical-girl components and existing 401/403 behavior.
## 6. Regression And Verification
- [x] 6.1 Add Platform/Run regression coverage for owner/endpoint/signature/session/attempt/lease/target rejection, persistence/recovery/idempotency/cancel, redaction, and blocked transfer/adapter channel isolation.
- [x] 6.2 Update protocol/API/domain documentation with implemented limits and explicitly excluded client-manager lifecycle, production signing/fleet rollout/scaling/alerts/plugin lifecycle, and real AI-provider integration.
- [x] 6.3 Run plugin manifest/SDK tests, Platform tests, platform_web tests/typecheck/build, independent Run tests, strict OpenSpec validation, structure, shell/compose checks, and both repository diff checks; record only passing evidence.
## Verification Evidence
- `plugins`: `npm run validate:manifest`, `npm run typecheck`, and `npm test` passed (18 tests).
- `platform`: `go test ./...` passed across api/config/domain/dto/model/repo/service/validator.
- `platform_web`: `npm test` passed (104 tests), `npm run typecheck`, and `npm run build` passed.
- independent `run`: `go test ./...` passed across api/config/protocol/runtime/spool.
- `openspec validate implement-dependency-installation-and-run-self-update --strict` passed.
- `scripts/check-structure.sh`, `bash -n scripts/*.sh`, `docker compose config`, `git diff --check`, and `git -C run diff --check` passed.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,78 @@
## Context
`domain.Job` is already the durable unit stored by MemoryStore, FileStore, and the repository-backed MySQLStore, but it currently contains only the user-visible lifecycle projection. `CoreService` separately owns an in-memory `jobLeases` map containing raw Run session tokens, lease tokens, attempts, cancel intent, and terminal fingerprints. Restarting the platform therefore makes accepted/running jobs impossible to authenticate or complete safely. The independent Run repository similarly uses an in-memory active assignment map and does not reconcile during startup.
The existing HTTP surface already separates control, jobs, logs, and artifacts and signs non-hello Run requests with the active Run session. This change must deepen those boundaries without introducing an all-in-one transport, exposing credentials to the browser/plugins, or implementing later execution/storage roadmap items.
## Goals / Non-Goals
**Goals:**
- Make every scheduling decision recoverable from the Job repository after platform process restart or FileStore/MySQLStore reload.
- Fence every ack, progress, result, cancel poll, and reconcile report by endpoint, active authenticated session, per-job attempt, and a lease credential whose persisted form is a hash.
- Define bounded ack and execution leases, monotonic per-job attempts and progress sequences, exponential retry backoff, terminal idempotency, and durable cancel intent/result.
- Make Run persist active assignments atomically under its workspace, reconcile immediately after registration, and recover or discard work according to the platform response.
- Keep safe browser projections and owner/platform-admin authorization aligned with existing server ownership checks.
- Preserve independent control/job/log/artifact paths and prove large transfer requests cannot block control or job requests.
**Non-Goals:**
- Production-grade distributed scheduling or multi-platform-replica compare-and-swap coordination.
- Process supervision, real config/file mutation, durable logs/artifacts/metrics/backups, dependency installation, Run self-update, client-manager lifecycle, or production scaling.
- Persisting raw Run session tokens, lease tokens, host paths, sockets, AI keys, component keys, or other credentials.
## Decisions
### Persist scheduling metadata on the Job aggregate
`domain.Job` and `model.Job` gain a nested retry policy plus queue, attempt, hashed lease, ack/lease deadline, last progress sequence, cancel, terminal, and reconciliation fields. The existing Job repository remains the only storage boundary, so MemoryStore, FileStore, and MySQLStore inherit the behavior through their existing typed snapshot/repository implementations.
This is preferred over retaining a service cache or introducing a second lease repository because transitions need one recoverable aggregate and the current stores do not provide cross-repository transactions. Raw lease tokens are generated with cryptographic randomness, returned only over the signed Run job channel, and stored as SHA-256 hashes.
### Use a deterministic per-job state machine
New jobs default to `queued`, attempt zero, a bounded retry policy, and an immediately eligible queue timestamp. Claim sweeps expired work for the endpoint, selects eligible `queued` or `retrying` work in stable creation order, increments that job's attempt, stores the lease hash and current Run session generation, and moves it to `accepted` with an ack deadline and execution lease deadline.
Ack before the deadline moves the attempt to `running` and renews its lease. Monotonic progress renews the running lease. Ack timeout, execution lease expiry, or an explicitly retryable failed result clears the lease and moves the job to `retrying` with exponential backoff when attempts remain; otherwise it records a terminal `failed` result. A pending cancel intent always resolves to `cancelled` rather than retrying when the lease expires. Old attempts and old lease tokens are rejected even after a newer attempt exists.
### Model cancellation as durable intent followed by durable result
Cancel before claim atomically records intent and a terminal cancelled result. Cancel after claim records idempotent intent for the assigned Run endpoint; polling is fenced by the current attempt and lease, and Run returns a normal terminal cancelled result. Repeated identical or compatible cancel requests return the existing intent/result projection. Cancellation never consumes another retry attempt.
### Reconcile with full active-attempt evidence
Run reports persisted active assignment evidence: job ID, attempt, and raw lease token over the signed job channel. Platform verifies the endpoint, stored attempt, lease hash, nonterminal state, and active Run session, then rebinds the lease to the current session generation and renews its deadline. The raw token is echoed only in the signed response and is never persisted.
Reported stale/unknown jobs are returned as discard IDs. Platform-active jobs absent from the Run journal are treated as abandoned and enter cancel resolution or retry policy. Reconciliation timestamps, counts, and outcome are persisted on every affected Job. This is preferred over recreating leases during reconciliation because recreating them would let stale attempts regain authority.
### Persist Run journal atomically and reconcile before claiming
Run stores a versioned JSON journal under `WorkspaceRoot`, using a temporary file plus rename and owner-only permissions. It writes the assignment before ack, updates it after ack/progress, and removes it only after an accepted terminal response or explicit platform discard. Worker startup registers, reconciles the loaded journal before any new claim, and re-executes platform-confirmed assignments under the same attempt. Corrupt journal data fails worker construction instead of silently forgetting active work.
### Keep API and UI projections credential-free
Platform user APIs expose attempt, max attempts, next retry time, ack/lease deadlines, cancel state/timestamps, terminal time, and last reconcile outcome. They omit lease hashes, raw lease tokens, session generation, secret refs, and host/runtime credentials. Existing `GetJobForSession`, `ListJobsForSession`, and cancel authorization continue to derive access from server owner/admin or platform-admin rules. Run-only handlers continue to require both the active session and request signature middleware.
### Preserve channel independence
Control, jobs, logs, and artifacts remain separate HTTP routes and clients. Run's worker loop executes heartbeat and scheduling work independently from spool/artifact transfer queues; neither the job protocol nor safe browser projection carries log bodies or artifact payloads. Isolation tests block log/artifact handlers while asserting heartbeat, ack, result, cancel, and reconcile complete.
## Risks / Trade-offs
- [FileStore and the current MySQLStore are process-local snapshot implementations, not distributed CAS schedulers] -> Serialize transitions with the existing service mutex and explicitly keep multi-replica production scheduling out of scope.
- [At-least-once recovery can repeat an interrupted operation] -> Preserve idempotency keys, fence attempts, and require bounded Run executors to be idempotent; later process supervision will refine resumability.
- [Legacy persisted jobs lack new fields] -> Normalize zero-value scheduling fields when read/claimed so existing queued and terminal records remain valid without destructive migration.
- [Hash-only lease storage means platform cannot recreate a lost Run lease credential] -> Require the durable Run journal to present the original token; otherwise the platform retries with a new attempt after reconciliation/expiry.
- [Clock skew between Run and platform] -> Treat platform timestamps as authoritative; Run does not decide lease validity locally.
## Migration Plan
1. Deploy the expanded model/repository projection and zero-value normalization before relying on new states.
2. Deploy platform protocol and scheduler behavior with compatibility for an empty legacy reconcile evidence list.
3. Deploy Run protocol and persistent journal, which reconciles immediately after registration before claiming.
4. Deploy the safe web projection and tests.
5. Rollback may read the expanded JSON while ignoring unknown fields, but accepted/running jobs should be allowed to reconcile or expire before rolling back to code that lacks durable fencing.
## Open Questions
None for this single-process scheduling milestone. Cross-replica transactional claiming and resumable supervised processes remain explicit later design work.
@@ -0,0 +1,32 @@
## Why
The platform currently persists the visible Job record but keeps leases, attempts, cancellation intent, and terminal idempotency in `CoreService` memory, so a platform restart loses fencing and recovery state. Run also keeps its active-job journal only in memory, preventing reliable reconciliation after Run restart and leaving ack timeouts, retry backoff, and late-result rejection underspecified.
## What Changes
- Persist queue scheduling, attempt, lease hash and deadlines, retry/backoff, terminal fingerprint, cancellation intent/result, progress sequence, and reconciliation metadata through the existing MemoryStore, FileStore, and MySQLStore Job repository boundary.
- Define claim, ack, lease renewal, retry-wait, terminal, cancel, and reconciliation transitions with attempt fencing and deterministic late-message rejection.
- Rebind a valid persisted lease to a newly authenticated Run session generation only through endpoint-scoped reconciliation; never persist raw Run session or lease credentials.
- Persist Run's active assignment journal locally, reconcile it after registration and restart, continue valid attempts, and discard platform-rejected or unknown work.
- Keep control, jobs, logs, artifacts, and the optional game-client bridge as independent request paths and execution queues so log/artifact backpressure cannot block heartbeat or job acknowledgement/result traffic.
- Extend platform APIs and the management console only with safe scheduling projections such as state, attempt, retry timing, cancel status, and reconcile status. Raw tokens, secret references, host paths, sockets, and credentials remain excluded.
- Add cross-store, API, frontend, and independent Run regression coverage for reload recovery, deadlines, fencing, retry, cancellation, reconciliation, authorization/signature failure, and channel isolation.
- Explicitly leave process supervision, real config/file execution, durable log/artifact/metric/backup storage, dependency installation, self-update, client-manager lifecycle, and production scaling to later changes.
## Capabilities
### New Capabilities
- `durable-job-scheduling`: Defines durable platform scheduling and Run reconciliation semantics, retry and cancellation state, security fencing, safe projections, and channel isolation.
### Modified Capabilities
None.
## Impact
- `platform/`: Job domain/model/repository projections, scheduler service, Run job protocol DTOs and validators, owner/admin/Run-service authorization, API handlers, and persistence/reload tests.
- `run/` independent repository: job protocol contracts, persistent journal, worker startup reconciliation, cancellation/result behavior, and isolated HTTP channel tests.
- `platform_web/`: safe Job API types, schemas, task-status presentation, and 401/403 regression coverage without visual-system redesign.
- `plugins/`: existing SDK and manifests are verified to remain platform-mediated; no raw Run credential or host access is added.
- OpenSpec: adds a cross-repository behavioral contract and verification checklist while leaving earlier completed changes unarchived.
@@ -0,0 +1,117 @@
## ADDED Requirements
### Requirement: Durable scheduling state
The platform SHALL persist queue eligibility, retry policy, attempt, lease hash and deadlines, progress sequence, cancellation intent/result, terminal fingerprint, and reconciliation metadata through the Job repository used by MemoryStore, FileStore, and MySQLStore. It MUST NOT require a `CoreService` memory map to recover active scheduling state and MUST NOT persist raw Run session or lease tokens.
#### Scenario: Platform reload preserves active attempt
- **WHEN** a claimed or running job is reloaded into a new platform service instance
- **THEN** the stored endpoint, attempt, hashed lease, deadlines, cancel intent, and retry metadata remain authoritative and a correctly signed current-session request with the matching lease is accepted
#### Scenario: File and MySQL reload preserve queued work
- **WHEN** queued or retry-wait work is persisted and the store is reopened
- **THEN** the same job becomes claimable only at its persisted eligibility time with its prior attempt count intact
### Requirement: Lease and attempt fencing
The platform SHALL issue cryptographically random per-attempt lease tokens, persist only their hashes, and fence job messages by Run endpoint, authenticated active session generation, job ID, monotonic per-job attempt, and matching lease token. Accepted jobs SHALL have an acknowledgement deadline and running jobs SHALL have a renewable execution lease.
#### Scenario: Ack deadline expires
- **WHEN** Run does not acknowledge a claimed job before its acknowledgement deadline
- **THEN** the platform rejects the late acknowledgement and schedules the job for a later attempt or records terminal failure when retry budget is exhausted
#### Scenario: Execution lease expires
- **WHEN** a running attempt sends no accepted progress or reconciliation before its lease expires
- **THEN** the platform clears that lease and applies retry or terminal policy durably
#### Scenario: Old attempt arrives late
- **WHEN** an ack, progress update, result, cancel poll, or reconciliation entry references an older attempt or lease
- **THEN** the platform rejects it without changing the current attempt or terminal result
#### Scenario: Invalid endpoint or session
- **WHEN** otherwise valid attempt evidence is signed by another endpoint, an expired or rotated session, or an invalid signature
- **THEN** the platform returns an authentication or authorization failure and leaves the job unchanged
### Requirement: Retry and terminal policy
Each job SHALL have a bounded retry policy with a maximum attempt count and exponential backoff capped by a maximum delay. Ack timeout, lease expiry, and explicitly retryable failure SHALL enter durable `retrying` state when budget remains. Succeeded, non-retryable failed, cancelled, and exhausted jobs SHALL be terminal and terminal replay SHALL be idempotent only for the same attempt and result fingerprint.
#### Scenario: Retry waits for backoff
- **WHEN** an attempt fails retryably and attempts remain
- **THEN** the job records the next eligible time and cannot be claimed before that time
#### Scenario: Retry claim increments attempt
- **WHEN** backoff has elapsed and Run claims the job again
- **THEN** the platform increments the per-job attempt and issues a different lease token
#### Scenario: Retry budget is exhausted
- **WHEN** another retryable failure occurs on the maximum attempt
- **THEN** the job becomes terminal failed and is never returned by claim
#### Scenario: Terminal replay conflicts
- **WHEN** Run replays the same terminal result fingerprint for the current terminal attempt
- **THEN** the platform returns the existing accepted result, while a different fingerprint or attempt is rejected
### Requirement: Idempotent durable cancellation
The platform SHALL authorize cancellation through existing owner/server-admin/platform-admin resource checks, persist cancellation intent, and persist its terminal result. Cancellation before claim SHALL complete immediately; cancellation after claim SHALL be delivered only to the fenced active attempt and SHALL resolve idempotently.
#### Scenario: Cancel before claim
- **WHEN** an authorized user cancels queued or retry-wait work
- **THEN** the job records both cancel intent and terminal cancelled result without being claimed
#### Scenario: Cancel after claim
- **WHEN** an authorized user cancels accepted or running work
- **THEN** matching Run cancel polling observes the durable intent and a cancelled result records durable completion
#### Scenario: Cancel is repeated
- **WHEN** the same authorized cancellation is requested or polled more than once
- **THEN** the platform returns the existing intent/result without creating another attempt or conflicting terminal state
#### Scenario: Cross-owner cancellation is denied
- **WHEN** a non-admin user attempts to cancel a job for a server they do not own or administer
- **THEN** the platform returns forbidden and does not persist cancel intent
### Requirement: Run restart and platform reconciliation
Run SHALL persist active assignments atomically before acknowledgement and reconcile them immediately after every registration before claiming new work. Reconciliation SHALL report job ID, attempt, and lease evidence; platform SHALL confirm only matching active attempts, rebind them to the current authenticated session generation, persist reconciliation metadata, and direct Run to discard stale or unknown entries. Platform-active entries absent from Run's report SHALL enter cancellation resolution or retry policy.
#### Scenario: Run restart resumes confirmed attempt
- **WHEN** Run restarts with a valid persisted active assignment and registers a rotated session
- **THEN** reconciliation confirms and rebinds the same attempt before Run resumes it or claims other work
#### Scenario: Run reports stale journal entry
- **WHEN** Run reports a terminal, unknown, wrong-endpoint, wrong-attempt, or wrong-lease journal entry
- **THEN** the platform does not reactivate it and instructs Run to discard it
#### Scenario: Platform restart accepts reconciliation
- **WHEN** platform restarts while Run retains a valid active journal entry
- **THEN** the platform validates it against persisted job metadata without relying on prior process memory
#### Scenario: Platform active work is missing from Run
- **WHEN** authenticated reconciliation omits an accepted or running job assigned to that endpoint
- **THEN** the platform records reconciliation loss and applies cancel or retry policy rather than silently leaving unrecoverable active work
### Requirement: Credential-free user projection
The platform user API and platform_web SHALL expose only safe scheduling projections, including state, attempt counts, retry timing, cancel status, and reconcile outcome. They MUST NOT expose raw or hashed lease tokens, Run sessions, secret references, host paths, sockets, or credentials, and SHALL preserve existing 401/403 handling and crystal-moonlight console styling.
#### Scenario: Authorized user reads job scheduling status
- **WHEN** a server owner, server administrator, or platform administrator reads an accessible job
- **THEN** the response includes safe attempt, retry, cancellation, terminal, and reconcile fields without credential material
#### Scenario: Unauthorized user reads another owner's job
- **WHEN** a user without resource access requests another server's job
- **THEN** the API returns forbidden or not found according to the existing resource policy and platform_web follows existing 401/403 handling
### Requirement: Independent channel priority
Platform and Run SHALL keep control, jobs, logs, and artifacts on independent request paths and execution queues. Blocking or retrying log/artifact transfer MUST NOT block control heartbeat or job claim, ack, progress, result, cancel, or reconciliation traffic, and job/control messages MUST NOT carry log bodies or artifact payloads.
#### Scenario: Artifact transfer blocks
- **WHEN** an artifact chunk request remains blocked
- **THEN** heartbeat and job acknowledgement/result/cancel/reconcile requests still complete within their own deadlines
#### Scenario: Log ingest blocks
- **WHEN** a log batch upload remains blocked or retries
- **THEN** control heartbeat and job lifecycle traffic continue independently
### Requirement: Explicit roadmap boundary
This change SHALL NOT claim production readiness for process supervision, real config/file execution, durable log/artifact/metric/backup storage, dependency installation, Run self-update, client-manager lifecycle, or production multi-replica scaling.
#### Scenario: Completion is reported
- **WHEN** the durable scheduling change passes implementation and verification
- **THEN** its handoff identifies those capabilities as remaining later-route work
@@ -0,0 +1,43 @@
## 1. Durable Platform Model
- [x] 1.1 Add Job domain/model scheduling, retry, lease, cancellation, terminal, and reconciliation fields with legacy zero-value normalization and validation.
- [x] 1.2 Persist and reload the expanded Job aggregate through MemoryStore, FileStore, and MySQLStore repository paths without raw lease or session credentials.
## 2. Platform Scheduler State Machine
- [x] 2.1 Replace the CoreService in-memory job lease map with repository-backed claim, ack, lease renewal, progress sequencing, retry backoff, and terminal fencing transitions.
- [x] 2.2 Implement idempotent cancel-before-claim, cancel-after-claim polling/result, deadline expiry, late-message rejection, and exhausted retry behavior.
- [x] 2.3 Implement endpoint/session-generation reconciliation using attempt and lease evidence, including missing/stale work handling and persisted reconcile outcomes.
## 3. Contracts, Security, And API
- [x] 3.1 Extend platform Run job DTO/domain/validator/protocol contracts for deadlines, retryable result, full reconciliation evidence, and discard outcomes.
- [x] 3.2 Preserve signed Run-service endpoint/session checks and owner/server-admin/platform-admin authorization for job read and cancellation APIs.
- [x] 3.3 Expose only credential-free user Job scheduling projections and update API contract documentation.
## 4. Independent Run Recovery
- [x] 4.1 Extend the independent Run job protocol and client for deadlines, retry outcomes, reconciliation evidence, and discard instructions.
- [x] 4.2 Implement an atomic, owner-only persistent Run job journal and startup reconciliation before new claims.
- [x] 4.3 Make the worker recover confirmed attempts, handle cancellation idempotently, retain unaccepted results for later reconciliation, and discard platform-rejected entries.
- [x] 4.4 Prove control/job requests remain independent while log or artifact transfers block.
## 5. Safe Console Projection
- [x] 5.1 Extend platform_web API types and schemas with safe attempt, retry, cancel, terminal, and reconcile fields while preserving shared visual styles and 401/403 behavior.
- [x] 5.2 Update runtime task status presentation and tests without exposing lease/session/secret/host data or redesigning the console.
## 6. Regression And Completion Evidence
- [x] 6.1 Add platform tests for queue/store reload, lease and ack expiry, attempt fencing, retry/backoff, cancellation timing/idempotency, late messages, restart reconciliation, endpoint/owner rejection, and signature/session failure.
- [x] 6.2 Add independent Run tests for journal reload, restart reconciliation/recovery, cancel/result retention, stale discard, and channel isolation.
- [x] 6.3 Run plugin manifest/SDK tests, platform Go tests, platform_web tests/typecheck/build, independent Run tests, strict OpenSpec validation, structure check, shell/compose checks where affected, and both repositories' diff checks; then record evidence before checking tasks complete.
## Verification Evidence (2026-07-18)
- `platform`: `go test -count=1 ./...` passed across API, config, domain, DTO, model, repository, service, and validator packages.
- `platform_web`: 92 tests passed across 17 files; `npm run typecheck` and production `npm run build` passed.
- `plugins`: 17 manifest/SDK tests passed; TypeScript typecheck passed; dev, SCUM, and Minecraft example manifests validated.
- Independent `run`: `go test -count=1 ./...` passed across API, config, protocol, runtime, and spool packages.
- `openspec validate implement-durable-job-scheduling-and-reconciliation --strict`, `scripts/check-structure.sh`, shell syntax checks, and `docker compose config --quiet` passed.
- `git diff --check` passed in both `/Users/tasia/Desktop/code/browser` and the independent `/Users/tasia/Desktop/code/browser/run` repositories.
@@ -0,0 +1,53 @@
## Context
Earlier changes introduced channelized log ingest and artifact transfer contracts, but the Platform service keeps log batches, artifact transfer state, and artifact payload bytes in process memory. Metrics are currently generated from a live snapshot, backups are not represented, and Run remote access execution is a success-only placeholder. The repository already has file and MySQL metadata snapshots, signed Run requests, endpoint/session fencing, scoped plugin permissions, and safe frontend projections. This change extends those boundaries without replacing them.
## Goals
- Make restart behavior explicit and testable for logs, artifacts, metrics, and backups.
- Keep large bodies outside lightweight job/control payloads and expose only bounded, redacted projections to users, plugins, and platform_web.
- Make remote adapters declaration-driven and auditable, with cancellation and lease/attempt fencing handled by the existing job channel.
- Preserve independent channel priorities so slow artifact or adapter work cannot delay control heartbeat, job ack/result, or log acknowledgement.
## Decisions
### Decision 1: File-backed durable bodies behind service interfaces
Log segments, artifact chunks, and completed artifact content use owner-only files under configured private roots. Metadata and cursors remain in the existing repository snapshot (file or MySQL). Atomic temp-file rename, bounded reads, checksum verification, and startup reconstruction keep writes recoverable. Memory stores remain available for unit tests.
### Decision 2: Transfer manifests are the recovery source of truth
Artifact transfer sessions persist an idempotency key, owner scope, direction, size/chunk limits, received indexes, and final checksum. A restart reloads incomplete manifests and reconstructs next-missing state without exposing payload paths. A chunk is removed from the Run queue only after an acknowledgement for the exact transfer/artifact/index.
### Decision 3: Retention is bounded and deterministic
Log streams carry bounded retention count/age metadata; metric samples and backup records have configurable maximum records/bytes and oldest-first pruning. Pruning emits an audit record and never changes an acknowledged log sequence or an available artifact checksum. Recovery marks interrupted backups/receipts as recoverable failure rather than claiming success.
### Decision 4: Metrics and backups are append-only records with safe projections
Metric samples store server/run identity, timestamp, bounded numeric values, and source. Backup records store logical scope, artifact reference, checksum, size, state, and recovery/audit status. Host paths, credentials, sockets, PID, session/lease tokens, secret refs, and hashes used for fencing are excluded from response DTOs.
### Decision 5: Remote adapters are a constrained registry, not a command tunnel
The Platform registers adapter declarations from an installed plugin/runtime profile and authorizes a request only when owner/admin scope, server instance, selected endpoint, declared capability, and target allowlist all match. Run accepts a typed adapter kind and logical target key, validates timeout and retry bounds, checks context cancellation before and during work, and returns a safe result reference. Shell source, arbitrary command vectors, raw socket addresses, unapproved hosts, and embedded credentials are rejected. Existing Job attempt/lease/session fencing remains authoritative.
### Decision 6: Lightweight routes stay isolated
Control and job routes continue to reject log/artifact payload fields. Log ingest and artifact transfer use separate clients/queues. Remote adapter work is scheduled as a job and its result is metadata-only; it cannot write through control or log endpoints. Tests exercise interleaving and blocked/slow operations with bounded deadlines.
## Data Flow
1. Run appends a bounded log batch or artifact chunk to its owner-only spool/queue before upload.
2. A low-priority uploader sends the batch/chunk on its dedicated route; the Platform validates signature, endpoint/session, owner scope, sequence/range/checksum, and idempotency before durable append.
3. Platform persists metadata and body/manifests atomically, returns an acknowledgement, and the Run removes only the acknowledged item.
4. Metrics and backup records are written through bounded service methods, pruned deterministically, and queried through authorized safe DTOs.
5. A declared remote adapter request becomes a fenced job. Run executes only the typed adapter implementation, returns a bounded status/result ref, and Platform audits/project results after terminal fencing.
## Non-Goals And Follow-Ups
- No dependency installation, Run self-update, client-manager lifecycle, plugin lifecycle, production scaling/alerts, external object stores, arbitrary FTP/rsync/DB/RCON network access, or real AI provider integration.
- Native OS process birth tokens and distributed transaction guarantees remain outside this change.
## Rollback
The additive protocol and repository fields are backward compatible. Removing the new capabilities stops advertising durable/adapter features and leaves old metadata untouched; incomplete transfer manifests remain private and can be retried by a compatible release.
@@ -0,0 +1,29 @@
## Why
The platform has typed log and artifact routes, but restart recovery is incomplete: log acknowledgements and artifact transfer state are not durable, metrics are derived on demand, and backups and restricted remote adapters have no durable ownership/audit model. This change makes those existing channels operationally durable while preserving the control/job/log/artifact priority boundaries established by earlier changes.
## What Changes
- Persist log stream batches, acknowledgement cursors, retention metadata, and bounded queries across Platform restarts; keep Run local spool files retryable and recoverable.
- Persist artifact metadata, transfer sessions, chunk manifests, checksums, and content through a restart-safe bounded file-backed store; keep chunk retries idempotent and lower priority than logs/jobs/control.
- Add bounded metrics samples and backup records with retention, size limits, recovery status, and audit events; expose only safe projections.
- Add declaration-backed remote adapter requests for approved FTP/rsync/run-file/process/database/RCON operations with scoped targets, timeout/cancel/retry/fencing, and audit outcomes. No arbitrary shell, raw socket, unapproved host/credential, or bypass of Platform ownership/endpoint/session checks.
- Add Platform and Run protocol/client/runtime contracts plus platform_web safe status projections and regression coverage.
## Capabilities
### New Capabilities
- `durable-observability`: durable logs, artifacts, metrics, backups, retention, recovery, and safe query projections.
- `scoped-remote-adapters`: declared and authorized remote adapter execution with bounded lifecycle and audit semantics.
### Modified Capabilities
- `log-ingest-pipeline`: durable acknowledgement and restart recovery replace the earlier in-memory service assumption.
- `artifact-transfer-channel`: transfer manifests and content survive restart and retain idempotent chunk/checksum behavior.
## Impact
- Affects `platform/`, independent `run/`, and projection-only `platform_web/` contracts/views.
- Adds dedicated domain, DTO, model, repository, service, protocol, validator, and runtime types; no Run source is copied into the main repository.
- Does not implement dependency installation, Run self-update, client-manager lifecycle, plugin lifecycle, production scaling/alerts, or real third-party AI provider integration.
@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Durable logs recover after restart
The system SHALL persist accepted log batches, acknowledged sequence state, retention metadata, and bounded query indexes so a Platform restart does not duplicate or lose acknowledged ranges.
#### Scenario: Restart preserves log cursor
- **WHEN** a batch is acknowledged, Platform restarts, and a caller queries after a cursor
- **THEN** the ordered entries and latest acknowledged sequence MUST be available from the persisted store
#### Scenario: Retry remains idempotent
- **WHEN** Run retries an acknowledged batch with the same stream, range, and checksum
- **THEN** Platform MUST return an idempotent acknowledgement without duplicating entries
### Requirement: Run log spool is restart-safe
The Run log spool SHALL atomically persist unacknowledged batches, tolerate a process restart, and remove a batch only when an acknowledgement covers its full stream range.
#### Scenario: Interrupted enqueue
- **WHEN** a process restarts after an incomplete temporary spool write
- **THEN** the next spool load MUST ignore temporary files and retain every committed unacknowledged batch
### Requirement: Durable artifacts recover with checksum and chunk bounds
The system SHALL persist artifact metadata, transfer manifests, received chunk indexes, chunk checksums, final checksums, and bounded content so uploads can resume after restart.
#### Scenario: Resume missing chunk
- **WHEN** a transfer has received some chunks and Platform restarts
- **THEN** status MUST return the same received indexes and next missing index without exposing storage paths
#### Scenario: Checksum conflict is rejected
- **WHEN** a retry uses a different payload or checksum for an already received chunk
- **THEN** Platform MUST reject it and leave the original chunk and transfer state unchanged
### Requirement: Metrics and backups are durable and bounded
The system SHALL persist metric samples and backup records, apply explicit age/count/byte retention, support recovery status, and expose only owner-authorized safe projections.
#### Scenario: Retention prunes oldest records
- **WHEN** a metric or backup append exceeds its configured bound
- **THEN** the oldest records MUST be pruned deterministically and an audit event MUST record the retention result
#### Scenario: Interrupted backup is recoverable
- **WHEN** a backup remains in an incomplete state during restart
- **THEN** it MUST be projected as failed/recoverable with an audit outcome and MUST NOT claim an available artifact
### Requirement: Safe queries enforce ownership
The system SHALL authorize log, artifact, metric, and backup queries by platform session and server ownership/admin scope, returning bounded pages/cursors and never returning host paths, credentials, sockets, Run tokens, leases, session hashes, or secret references.
#### Scenario: Cross-owner query
- **WHEN** a user queries another owner's resource
- **THEN** the service MUST reject with the existing 403 behavior and MUST NOT reveal whether private body data exists
@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Remote adapters are declared and scoped
The system SHALL accept only typed adapter kinds and logical target keys declared by the installed plugin/runtime profile and selected Run endpoint.
#### Scenario: Undeclared adapter
- **WHEN** a request names an adapter or target not declared for the server and endpoint
- **THEN** Platform MUST reject it before creating a job
#### Scenario: Unsafe target data
- **WHEN** a request contains shell source, raw socket addresses, host paths, credentials, or unbounded inline query/command data
- **THEN** validation MUST reject it and MUST NOT persist the unsafe fields
### Requirement: Adapter lifecycle is bounded and fenced
The system SHALL enforce timeout, cancellation, retry, endpoint/session, attempt, and lease fencing using the existing job channel.
#### Scenario: Cancelled adapter
- **WHEN** cancellation arrives before or during adapter execution
- **THEN** Run MUST stop at a bounded checkpoint and return a cancelled safe result; Platform MUST not apply a stale terminal result
#### Scenario: Stale attempt result
- **WHEN** an older attempt reports success after a newer attempt owns the lease
- **THEN** Platform MUST reject the result and retain the newer job state
### Requirement: Adapter results are auditable projections
The system SHALL persist an audit event for authorization, timeout, cancellation, success, and failure outcomes and expose only adapter kind, target key, status, bounded message, and safe result references.
#### Scenario: Successful scoped adapter
- **WHEN** a declared adapter completes within its deadline
- **THEN** the operator MUST see a safe status and audit summary without raw host/credential/socket details
### Requirement: Channel isolation is maintained
Remote adapter work and artifact transfer SHALL use lower-priority independent work paths and MUST NOT delay control heartbeat, job ack/result, or log upload acknowledgement beyond their deadlines.
#### Scenario: Slow adapter and artifact transfer
- **WHEN** adapter or chunk work blocks or retries
- **THEN** control, job lifecycle, and log acknowledgement calls MUST remain independently completable
@@ -0,0 +1,29 @@
## 1. OpenSpec And Contracts
- [x] 1.1 Add durable log/artifact/metrics/backup/remote adapter domain, DTO, model, protocol, and validator contracts with forbidden-field tests.
- [x] 1.2 Extend file/MySQL snapshot and repository interfaces for durable bodies, transfer manifests, samples, backups, and audit projections.
- [x] 1.3 Update route/protocol/API contracts and platform_web safe types without exposing Run tokens, lease/session hashes, host paths, credentials, sockets, PID, or secret refs.
## 2. Durable Logs And Artifacts
- [x] 2.1 Make Platform log batches, cursors, retention, and file-backed body storage restart-safe and queryable with idempotent ack/retry.
- [x] 2.2 Make artifact transfer sessions/chunks/content durable, bounded, resumable, checksum-verified, and idempotent across restart.
- [x] 2.3 Make Run spool/queues atomic and restart-safe, and keep log/artifact upload scheduling independent from control/jobs.
## 3. Metrics, Backups, And Audit
- [x] 3.1 Persist bounded metrics samples with retention and authorized paginated safe queries.
- [x] 3.2 Persist backup records and recovery transitions with size/checksum/retention bounds and audit events.
- [x] 3.3 Add cross-owner/endpoint/signature/session rejection and safe projection tests for logs, artifacts, metrics, backups, and audits.
## 4. Scoped Remote Adapters
- [x] 4.1 Add declaration-backed adapter registry and Platform authorization/dispatch with timeout, cancel, retry, fencing, and audit semantics.
- [x] 4.2 Implement Run typed adapter execution checkpoints without arbitrary shell, raw sockets, unapproved hosts/credentials, or direct plugin access.
- [x] 4.3 Add remote adapter protocol/client/runtime tests for timeout, cancellation, stale attempt, wrong owner/endpoint, and safe result projection.
## 5. Frontend And Verification
- [x] 5.1 Add platform_web logs/artifacts/metrics/backups/adapter status, pagination/sequence/checksum/audit projections using existing theme primitives and auth handling.
- [x] 5.2 Add regression tests for restart/recovery/retention/checksum/idempotency and control/job/log/artifact channel isolation.
- [x] 5.3 Run plugin manifest/SDK tests, Platform Go tests, platform_web tests/typecheck/build, Run tests, strict OpenSpec validation, structure, shell/compose checks, and both repository diff checks; record only passing evidence.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,86 @@
## Context
Platform already persists server runtime profiles/bindings and durable Job scheduling metadata. Run already persists active assignments and pending terminal results, reconciles them after session rotation, signs Run-service requests, and keeps control/jobs/logs/artifacts on separate routes. The missing execution layer is narrower but security-sensitive: lifecycle start/stop are currently one-shot `exec.CommandContext` calls, config reads are platform-derived, and config/file jobs do not carry durable approved bytes or produce typed execution results.
The independent Run repository must remain separate. Browser/plugin callers name only a server, declared capability, logical target, and scoped input/artifact reference. Platform remains the authority for ownership, profile selection, approval, job attempt/session fencing, and safe projection. Run alone maps the logical scope into its private workspace and may persist PID or filesystem details, none of which can cross into platform_web or plugin DTOs.
## Goals / Non-Goals
**Goals:**
- Supervise one declared local game process per server/profile scope with real spawn, liveness, graceful stop, exit observation, idempotent operations, and restart reconciliation.
- Execute approved config/file reads and writes with strict containment, symlink/device rejection, bounded I/O, atomic replacement, and compare-and-swap version/checksum semantics.
- Preserve private execution input and typed result state across Platform/Run restarts without weakening task 04 lease, attempt, session-generation, signature, cancellation, or reconciliation rules.
- Project only safe process state, exit classification, config/file version, checksum, size, and audit summary to authorized owners/admins and platform_web.
- Keep current visual language and existing 401/403 handling.
**Non-Goals:**
- General shell execution, arbitrary interpreters, host path selection, direct sockets, credential injection, multiple unmanaged processes per server, or OS service/container orchestration.
- Durable log/artifact/metric/backup storage, remote FTP/rsync/database/RCON adapters, dependency installation, Run self-update, client-manager lifecycle, production scaling/alerts/plugin lifecycle, or real AI-provider integration.
- Claiming that example action declarations install or ship commercial game binaries.
## Decisions
### Decision 1: Extend the durable Job aggregate with private execution input and typed result
`Job` will persist a nested execution input containing the workspace profile key, approved content, expected version/checksum, and bounded read limit, plus a nested typed result containing kind, process state, exit classification, version, checksum, size, and audit summary. Model conversions and file/MySQL snapshot tests will cover the fields. The user `JobResponse` will omit approved content and expose only the safe result subset; the signed Run assignment carries the private input only after a matching claim.
This is preferred over an in-memory input map because queued/retrying work must survive Platform restart. A separate input repository was considered, but the input has the same lifecycle, idempotency, ownership, and retention as its Job and would add cross-record transaction failure without providing reuse.
### Decision 2: Resolve lifecycle action ref and workspace scope at dispatch
Platform will load the server's persisted runtime binding and selected plugin lifecycle profile, verify the requested capability/action is declared, set the Job target to that action's relative JSON ref, and set the private workspace scope to the selected profile key. Config/file dispatch uses the same binding-derived scope and validates the endpoint, plugin permission, server ownership, logical target, and scoped input/artifact ref before Job creation.
This replaces the current mismatch where lifecycle jobs send the profile key as `targetKey` while Run expects an action file ref. Platform will never send a host path or binding value to browser/plugin callers.
### Decision 3: Typed action files distinguish one-shot and supervised operations
Run will decode action JSON with unknown-field rejection, bounded size, and an explicit action matching the Job capability. `start` requires a workspace-relative executable key and argument vector; `stop` and `status` operate only on the persisted supervised identity for that server/profile; `install` remains a bounded one-shot declaration and does not represent dependency installation. Executable resolution rejects symlinks, non-regular/non-executable files, shells, interpreter escape forms, unsafe environment names/values, and paths outside the scoped workspace.
This keeps plugins declarative and supports actual process execution without accepting shell source or a generic command string. Keeping the existing unrestricted PATH lookup was rejected because names such as interpreters can turn a safe-looking vector into arbitrary execution.
### Decision 4: Run owns a private process journal and liveness probe
Run will atomically persist owner-only process records under its state directory. Records include logical server/profile identity, private PID, start time, command fingerprint, state, exit classification, and the latest owning Job attempt/lease hash, but not raw session tokens or environment credentials. A live in-process waiter records unexpected exits. On Run startup, reconciliation probes each recorded PID, retains only matching live identities, and marks missing identities exited before new claims execute.
Start is idempotent when a matching process is live; stop is idempotent when absent/stopped. Cancellation or timeout before start commit terminates the child. Attempt evidence prevents an older recovered assignment from replacing or stopping a newer process record. PID is intentionally private and never appears in Platform protocol results.
OS liveness primitives provide bounded best-effort identity validation. Strong native start-token adapters for every supported OS remain future hardening; command fingerprint and journal start metadata reduce PID-reuse ambiguity in this implementation.
### Decision 5: Secure workspace access validates every path component
The effective workspace is `<private-root>/instances/<server-id>/<profile-key>`, constructed only from validated logical identifiers. Reads and writes walk components with `Lstat`, reject symlinks, absolute/traversal/backslash keys, reserved Run state/action targets for generic writes, non-directory parents, device/FIFO/socket files, and any resolved path outside the scope. Reads require regular files and enforce a configured maximum before and during reading.
Writes compare current file metadata with expected version/checksum, create an owner-only temporary regular file in the same verified directory, write and fsync bounded bytes, recheck containment, rename atomically, fsync the parent where supported, and atomically update an owner-only file metadata journal. A conflict performs no rename. This is preferred over `os.WriteFile`, which can follow symlinks and expose partial content.
### Decision 6: Platform applies terminal typed results only after existing fencing
Run returns typed execution results on the existing Job result route with Job ID, attempt, lease token, and current signed session. Platform validates type/capability consistency, bounds/redacts fields, includes the typed result in the terminal fingerprint, and stores/applies it only after the existing endpoint/session-generation/attempt/lease/deadline/cancel checks pass.
A successful config write advances the persisted server config version and checksum and stores the approved content already attached to that Job. File reads store private bounded content on the Job but expose only checksum/size/version in the normal job projection. Lifecycle process state updates the server projection without trusting Run-supplied PID or path data. Stale/conflicting terminal results cannot mutate server config or process projection.
### Decision 7: Frontend changes are projection-only
`platform_web` will add schema validation for the safe execution result, show config checksum/version and process/file audit outcomes in existing server detail/job surfaces, and continue to use the current preview-then-approve flow for AI or manual config changes. It will not render approved private bytes from Job records, PID, host paths, Run tokens, leases, secret refs, sockets, or credentials, and it will reuse shared theme surfaces.
## Risks / Trade-offs
- [A process can exit between a liveness probe and an idempotent response] -> Record the latest observed state, keep probes bounded, and treat later status as authoritative rather than claiming continuous availability.
- [PID reuse after a long Run outage can produce ambiguous recovery on some OSes] -> Persist start metadata and command fingerprint, reject inconsistent identities, document best-effort recovery, and leave stronger per-OS birth-token adapters as follow-up hardening.
- [Platform snapshot persistence of approved config bytes increases metadata size] -> Keep input/read limits at 64 KiB and never store large artifacts in Job input; larger payloads stay artifact-referenced and are outside this change's durable artifact claim.
- [Atomic rename durability differs by filesystem] -> fsync file and parent where supported, keep same-directory temporary files, and test visibility/cleanup semantics without claiming distributed-filesystem guarantees.
- [Existing example workspaces may not contain supervised executables] -> Preserve bounded install behavior, make start failures explicit and retry policy-aware, and use controlled executable fixtures in Run tests.
## Migration Plan
1. Add backward-compatible zero-value execution input/result and config checksum/content fields to Platform domain/model/snapshot conversions.
2. Add protocol DTOs/validators and result projection before enabling Run capabilities.
3. Deploy Run secure workspace, process/file journals, executor, and worker dispatch; old journals load with empty new fields.
4. Update plugin action schema/examples and Platform lifecycle target resolution.
5. Enable safe frontend projections after API fields are available.
6. Rollback ignores additive snapshot fields and stops advertising new Run capabilities; supervised child processes must be stopped through the old Run instance or operator-controlled host procedure before removing its private journal.
## Open Questions
- Strong native process birth-token verification for every supported OS is intentionally deferred; this change uses the bounded liveness/fingerprint strategy described above.
@@ -0,0 +1,31 @@
## Why
Run currently executes lifecycle declarations as short-lived commands and platform config/file APIs only enqueue logical placeholders. The system therefore cannot supervise a real game process across Run restarts or prove that an approved, fenced config/file job performed a bounded workspace operation.
## What Changes
- Replace one-shot start/stop behavior with a restricted process supervisor that consumes plugin-declared typed action files and argument vectors, persists private process identity, reconciles surviving processes after Run restart, and provides idempotent start/stop/status outcomes without arbitrary shell execution.
- Execute `config.write`, `files.read`, and `files.write` jobs inside a profile-scoped Run workspace with lexical and filesystem containment, symlink/device rejection, bounded reads, atomic writes, version/checksum compare-and-swap, cancellation, and attempt fencing.
- Persist approved execution inputs and typed safe results on platform jobs so Platform restart does not lose a queued write body, expected version/checksum, process state, file checksum, size, or audit summary.
- Resolve lifecycle action refs and workspace scope from the server's persisted plugin runtime profile/binding while retaining owner/platform-admin/Run-service authorization and signed Run channel boundaries.
- Project successful lifecycle and config results into durable server state/config metadata and expose only safe process/config/file result fields to platform_web with existing 401/403 behavior and visual system.
- Extend plugin manifest validation and examples for typed lifecycle action contracts and add cross-repository regression coverage for process reconciliation, stale/cancelled attempts, path safety, atomic versioned file operations, ownership, signatures, and channel isolation.
- Keep durable logs/artifacts/metrics/backups, remote adapters, dependency installation, Run self-update, client-manager lifecycle, production scaling/alerts/plugin lifecycle, and real AI-provider integration outside this change.
## Capabilities
### New Capabilities
- `bounded-process-supervision`: Restricted typed process start/stop/status, private durable identity, idempotency, exit observation, and Run restart reconciliation.
- `scoped-config-file-execution`: Platform-approved durable inputs and typed results for versioned, atomic, bounded config/file operations inside a contained Run workspace.
### Modified Capabilities
## Impact
- `plugins/`: lifecycle action schema/types, example declarations, manifest validation, and SDK tests; plugins still receive no Run transport or machine details.
- `platform/`: job/domain/model persistence fields, lifecycle/config/file services, authorization, Run protocol DTOs and validators, result projection, routes/contracts, and tests.
- Independent `run` repository: protocol mirrors, persistent process/file journals, secure workspace resolver, process supervisor, config/file executor, worker dispatch/recovery, and channel tests.
- `platform_web/`: safe API/job schemas and server detail status/config checksum presentation only; no unrelated redesign.
- Public API responses gain safe execution result metadata, while private approved content, PID, host paths, credentials, sessions, leases, and hashes remain outside user DTOs.
@@ -0,0 +1,68 @@
## ADDED Requirements
### Requirement: Lifecycle execution uses declared typed actions
Platform and Run SHALL execute local lifecycle jobs only from the server's selected plugin runtime profile and a bounded typed action declaration whose action matches the requested capability. Run MUST reject arbitrary shell, unrestricted PATH execution, absolute executables, undeclared environment fields, and unsafe argument content.
#### Scenario: Declared start action executes
- **WHEN** an authorized start Job carries the selected profile scope and its declared relative action ref
- **THEN** Run validates the typed start declaration and starts only the workspace-contained executable with the declared argument vector
#### Scenario: Shell or mismatched action is rejected
- **WHEN** an action declaration contains shell execution, an unsafe executable, or an action different from the Job capability
- **THEN** Run fails the Job without creating a supervised process
### Requirement: Process start and stop are real and idempotent
Run SHALL supervise at most one matching game process per server/profile scope and SHALL make repeated start and stop operations converge without creating duplicate processes or failing solely because the desired state already exists.
#### Scenario: Start already-running process
- **WHEN** a start Job targets a scope whose matching supervised process is alive
- **THEN** Run returns a successful typed `running` result and does not spawn another process
#### Scenario: Stop running process
- **WHEN** a stop Job targets a live supervised process
- **THEN** Run requests bounded graceful termination, escalates only within the declared policy, records the exit, and returns a safe `stopped` result
#### Scenario: Stop already-stopped process
- **WHEN** a stop Job targets a scope with no live supervised process
- **THEN** Run returns an idempotent successful `stopped` result without exposing process identifiers
### Requirement: Process state survives Run restart reconciliation
Run SHALL persist private controlled process identity/state atomically with owner-only permissions and SHALL reconcile every record against OS liveness before accepting new lifecycle work after startup or session rotation.
#### Scenario: Live process survives Run restart
- **WHEN** Run restarts while a recorded supervised process remains alive
- **THEN** startup reconciliation retains the logical process as `running` and a later status/start operation observes the same process rather than spawning a duplicate
#### Scenario: Process exited while Run was offline
- **WHEN** a recorded process is no longer alive during startup reconciliation
- **THEN** Run records a safe exited state and does not treat the stale PID as running
### Requirement: Unexpected exits and status queries are typed
Run SHALL observe exits of processes it starts and SHALL return bounded typed state, exit classification, timestamps, and audit summary for status Jobs without returning PID, host path, command bytes, environment credentials, sockets, sessions, leases, or hashes.
#### Scenario: Managed process exits unexpectedly
- **WHEN** a supervised process exits without a completed stop operation
- **THEN** Run records an unexpected-exit classification and a subsequent status result reports `exited` with bounded safe evidence
#### Scenario: User reads process result
- **WHEN** an authorized owner or administrator reads the completed lifecycle/status Job
- **THEN** Platform returns safe process state and exit classification and omits all private machine identity and fencing fields
### Requirement: Process operations honor cancellation and attempt fencing
Run SHALL bind process mutations to the current reconciled Job attempt and Platform SHALL apply typed terminal results only after endpoint, session generation, attempt, lease, deadline, cancellation, and signature checks succeed.
#### Scenario: Start is cancelled before commit
- **WHEN** the current start attempt is cancelled or times out before Run commits its process record
- **THEN** Run terminates any child created by that attempt and returns a cancelled result
#### Scenario: Stale attempt reports process result
- **WHEN** an older attempt or stale session submits a process result after retry/reconciliation
- **THEN** Platform rejects it and does not change the server process projection
### Requirement: Process traffic remains channel-isolated
Process execution, monitoring, and result reporting SHALL use the existing Job channel and MUST NOT block control heartbeat or Job acknowledgement/result traffic when log or artifact work is blocked.
#### Scenario: Artifact or log request blocks during process operation
- **WHEN** a log upload or artifact transfer remains blocked while a process Job completes
- **THEN** control heartbeat and the process Job acknowledgement/result continue through their independent paths
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Platform persists approved bounded execution input
Platform SHALL authorize config/file operations against server ownership, plugin permissions, endpoint ownership, selected runtime binding, declared logical target, and scoped input/artifact reference before creating a Job. Approved bounded bytes, workspace profile, expected version/checksum, and read limit SHALL persist with the Job and SHALL remain private from user Job DTOs.
#### Scenario: Approved config survives Platform restart
- **WHEN** a reviewed config write is queued and Platform restarts before Run claims it
- **THEN** the same approved content, logical ref, expected version/checksum, and workspace scope remain available to the fenced Run assignment
#### Scenario: Cross-owner or wrong-endpoint dispatch is attempted
- **WHEN** a caller lacks server authority or a Job/ref belongs to another server or endpoint
- **THEN** Platform rejects the request without persisting or dispatching execution input
### Requirement: Workspace resolution prevents boundary escape
Run SHALL map the Platform-approved server ID and profile key into a private workspace and SHALL reject absolute paths, traversal, backslashes, symlinked components, symlink targets, reserved state/action targets, non-directory parents, device files, FIFOs, sockets, and any path outside the selected scope.
#### Scenario: Traversal or absolute target is submitted
- **WHEN** a config/file Job contains a traversal, absolute, or otherwise invalid logical target
- **THEN** Platform or Run rejects it before filesystem access
#### Scenario: Symlink escapes workspace
- **WHEN** any parent or final target is a symlink that resolves inside or outside the workspace
- **THEN** Run rejects the operation and leaves the referenced file unchanged
#### Scenario: Device or special file is targeted
- **WHEN** a read or write resolves to a device, FIFO, socket, or other non-regular file
- **THEN** Run rejects the operation without opening the special file
### Requirement: Config and file writes are atomic compare-and-swap operations
Run SHALL enforce bounded input, compare the current controlled version/checksum to the expected values, write an owner-only temporary regular file in the verified target directory, fsync and atomically rename it, and persist updated version/checksum metadata only after success.
#### Scenario: Atomic write succeeds
- **WHEN** expected version/checksum match and the approved input is valid
- **THEN** readers observe either the complete old content or complete new content and Run returns the incremented version, checksum, size, and safe audit summary
#### Scenario: Expected version conflicts
- **WHEN** the current controlled version differs from `expectedVersion`
- **THEN** Run returns a typed conflict and does not replace the file or metadata
#### Scenario: Expected checksum conflicts
- **WHEN** the current file checksum differs from `expectedChecksum`
- **THEN** Run returns a typed conflict and leaves content/version unchanged
#### Scenario: Write is cancelled before rename
- **WHEN** cancellation or attempt invalidation is observed before atomic commit
- **THEN** Run removes the temporary file and leaves the prior target/version unchanged
### Requirement: Reads are bounded and typed
Run SHALL read only regular contained files, enforce the assignment's maximum before and during I/O, compute SHA-256, and return a typed result with private bounded content plus safe version/checksum/size/audit metadata.
#### Scenario: Bounded read succeeds
- **WHEN** a contained regular file is no larger than the approved limit
- **THEN** Run returns its exact bounded content privately and reports matching checksum, size, and controlled version
#### Scenario: File exceeds read limit
- **WHEN** file metadata or streamed bytes exceed the approved limit
- **THEN** Run fails with a bounded size error and does not return partial content
### Requirement: Terminal config results update durable Platform state
Platform SHALL validate typed result/capability consistency after existing fencing, persist the safe result, and only then project a successful config write into the server's durable config content, checksum, version, and update time. Failed, cancelled, stale, or conflicting results MUST NOT mutate config state.
#### Scenario: Config write result is accepted
- **WHEN** the current fenced attempt returns a successful config result matching its approved content checksum and next version
- **THEN** Platform updates the server config and authorized config reads return the new content/version/checksum
#### Scenario: Stale config result arrives
- **WHEN** a stale attempt, invalid signature/session, wrong endpoint, or cancelled attempt returns a config result
- **THEN** Platform rejects it and preserves the prior config content/version/checksum
### Requirement: AI suggestions remain review-before-write
AI-assisted configuration SHALL continue to produce a reviewable diff and MUST NOT dispatch a config write until an authorized user approves that diff with the current expected version/checksum.
#### Scenario: AI suggestion is generated
- **WHEN** AI proposes configuration content
- **THEN** Platform and platform_web show a reviewable diff without creating a Run write Job
#### Scenario: User approves suggestion
- **WHEN** an authorized user approves the current diff
- **THEN** Platform persists the approved input and dispatches it through the normal fenced config Job path
### Requirement: User projections remain credential-free
Platform and platform_web SHALL expose only authorized process/config/file state, version, checksum, size, conflict/error classification, and bounded audit summary, and MUST NOT expose approved private Job content, raw AI keys, Run tokens, leases/hashes, secret refs, host paths, PID, sockets, or credentials.
#### Scenario: Authorized result is rendered
- **WHEN** an authorized user views config or operation history
- **THEN** platform_web renders safe typed metadata using existing theme surfaces and existing 401/403 handling
#### Scenario: Plugin requests a file operation
- **WHEN** a plugin page submits a declared scoped file request
- **THEN** it receives only the Platform-owned Job/safe result projection and no direct Run or workspace information
### Requirement: Config/file traffic remains channel-isolated
Config/file execution SHALL use bounded Job payloads and MUST NOT carry log batches or artifact chunks. Slow config/file I/O MUST NOT block control heartbeat, Job acknowledgement/result, or the independent log/artifact routes.
#### Scenario: File execution blocks
- **WHEN** a file executor is deliberately blocked
- **THEN** control heartbeat and unrelated Job acknowledgement/result requests continue within their own deadlines
### Requirement: Roadmap boundary remains explicit
Completion of this capability MUST NOT be reported as readiness for durable logs/artifacts/metrics/backups, remote adapters, dependency installation, Run self-update, client-manager lifecycle, production scaling/alerts/plugin lifecycle, or real AI-provider integration.
#### Scenario: Change is handed off
- **WHEN** implementation and verification complete
- **THEN** the handoff identifies those later-route capabilities as not implemented by this change
@@ -0,0 +1,33 @@
## 1. Contracts And Persistence
- [x] 1.1 Extend plugin manifest schema, SDK types, examples, and validation tests for bounded typed lifecycle action declarations and refs.
- [x] 1.2 Add Platform domain/model fields for private Job execution input, typed safe/private result, and durable server config content/checksum with copy/conversion tests.
- [x] 1.3 Extend Platform and independent Run Job protocol DTOs/validators for workspace scope, expected version/checksum, bounded content/read limit, and typed results without changing lease/session fencing.
- [x] 1.4 Verify file and MySQL snapshot round trips preserve approved inputs/results/config metadata without exposing them through user Job DTOs.
## 2. Platform Dispatch And Projection
- [x] 2.1 Resolve lifecycle action refs and workspace scope from the persisted selected runtime profile/binding and add typed process status dispatch.
- [x] 2.2 Persist approved config/file bytes or bounded artifact payloads with expected version/checksum and enforce owner/admin, plugin, endpoint, and logical target authorization.
- [x] 2.3 Validate and persist typed Run terminal results only after existing signature/session/attempt/lease/deadline/cancel fencing.
- [x] 2.4 Project successful process/config results into durable server state/config metadata and add safe audit fields while rejecting stale, conflicting, cross-owner, and wrong-endpoint mutations.
- [x] 2.5 Update Platform routes/contracts/tests for process status, config checksum approval, bounded file inputs, safe typed Job results, and existing 401/403 behavior.
## 3. Independent Run Execution
- [x] 3.1 Implement a shared secure workspace resolver that rejects traversal, absolute/backslash keys, symlink components/targets, reserved writes, and special files.
- [x] 3.2 Implement an owner-only atomic file metadata journal and real bounded read/atomic CAS write executor with checksum/version conflicts and cancellation cleanup.
- [x] 3.3 Implement an owner-only atomic process journal, contained executable validation, real spawn/wait/stop/status supervision, idempotency, timeout, and unexpected-exit recording.
- [x] 3.4 Reconcile persisted process identities on Run startup/session rotation and fence stale attempts without persisting raw session tokens.
- [x] 3.5 Wire config/file/process capabilities through Worker claim/ack/progress/cancel/result/recovery and keep control/jobs independent from blocked log/artifact/file work.
## 4. Safe Frontend Projection
- [x] 4.1 Extend platform_web API types/schemas/tests for safe process/config/file result metadata and config checksum while rejecting forbidden machine/fencing fields.
- [x] 4.2 Render process/config/file version, checksum, size, and audit outcome in existing server detail/job surfaces using shared black-mecha/magical-girl theme primitives and existing auth error handling.
## 5. Regression And Verification
- [x] 5.1 Add Run regressions for idempotent start/stop, unexpected exit, restart reconciliation, stale/cancel, traversal/symlink/device escape, atomic write, CAS conflicts, bounded reads, and channel isolation.
- [x] 5.2 Add Platform regressions for durable private input, typed projection, config application, ownership/endpoint rejection, stale attempt, signature/session failure, AI review-before-approval, and channel isolation.
- [x] 5.3 Run plugin tests/typecheck/all manifest validation, Platform Go tests, platform_web tests/typecheck/build, independent Run tests, strict OpenSpec validation, structure check, shell/compose checks, and both repositories' `git diff --check`; record only passing evidence before marking complete.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,107 @@
## Context
The existing distribution workflow validates a plugin-declared client-manager profile, queues a real `distribution.build` job, injects a separate component key through authenticated build input, and publishes an available artifact only after chunked upload succeeds. It does not represent an installed instance, deploy the artifact through Run, supervise the companion process, authenticate the component as its own actor, reconcile health after Platform or Run restarts, or provide update/rollback/uninstall workflows.
The implementation spans the plugin contract, Platform persistence and APIs, the independent Run repository, and platform_web. Existing boundaries remain mandatory: Run is not reintroduced into this repository; browser/plugin callers never receive raw secrets, paths, PIDs, sockets, or endpoint addresses; job, artifact, log, control, and optional game-client traffic remain isolated; and existing dirty changes in both repositories must be preserved.
## Goals / Non-Goals
**Goals:**
- Carry a plugin-declared client manager from a real build artifact through authorized deployment, installation, registration, health, control, update/rollback, revocation, and safe uninstall.
- Persist a durable Platform aggregate and a Run-local journal so retries, cancellation, stale attempts, lease expiry, and restarts converge instead of reporting synthetic success.
- Authenticate Client Manager directly as a separate component identity with a short-lived session and heartbeat contract, while retaining the distinct singleton component key and generation already used at build time.
- Restrict Run execution to typed deployment and lifecycle operations inside a controlled workspace with checksummed artifacts and declarative executable/health metadata.
- Project safe, useful lifecycle state, real job progress, recovery actions, and audited confirmations into the existing game-operations console.
**Non-Goals:**
- Billing, cloud host sales, provider marketplaces, host provisioning, or a general remote administration surface.
- Arbitrary shell commands, plugin-selected host paths, direct browser/plugin access to Run or Client Manager sockets, or reuse of Run sessions/leases/keys for Client Manager.
- Production KMS, public code-signing trust, private-source credential management, or fleet-wide rollout orchestration. Existing envelope encryption and checksum verification remain the bounded first-party mechanisms.
- Claiming production readiness for client-manager fleets, production sandboxing, or later operations-console work.
## Decisions
### Decision 1: A durable installation aggregate owns lifecycle state
Platform adds one `ClientManagerInstallation` per server instance and profile. It references, but is not the same record as, a `ClientManagerDistribution`. The aggregate stores the assigned Run endpoint, target tuple, desired/active/previous artifact and version metadata, current component-key generation, deployment generation, lifecycle status and phase, current job, last successful job, health summary, last seen, retryable failure, and timestamps. Distribution states remain `building`, `available`, `failed`, or `revoked`; installation states cover `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `uninstalled`, and `failed`.
All transitions are validated in the service layer and persisted before dispatch. Terminal job projection and component heartbeat advance the aggregate idempotently. A startup/periodic reconciler rebuilds missing projections from durable jobs/sessions and moves timed-out health to `degraded` then `offline` without deleting history.
Alternative: derive lifecycle state from the latest build job and heartbeat. Rejected because it loses desired state, previous deployment, retries, uninstall history, and restart reconciliation.
### Decision 2: Run executes typed lifecycle jobs with strict fencing
Platform uses dedicated job kinds for `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Payloads name logical installation/profile/artifact IDs, target tuple, version/revision, component-key generation, deployment generation, action, checksum, and idempotency key only. Platform accepts them only when user/server/plugin/endpoint authorization, profile capability, endpoint capability, artifact ownership, target/revision compatibility, current key generation, and allowed state transition all agree.
Run validates the same immutable fields, leases jobs through the existing durable scheduler, and records attempt plus fencing generations in a local journal. Duplicate idempotency keys return the recorded outcome; a stale attempt or deployment generation cannot replace a newer active deployment. Cancellation is checked between artifact chunks and activation steps. Retriable failures retain staging state; permanent validation failures never execute.
Alternative: model lifecycle as generic commands or reuse game-server lifecycle jobs. Rejected because arbitrary commands are unsafe and client-manager identity/deployment semantics differ from both Run and the managed game server.
### Decision 3: Deployment uses controlled slots and atomic activation
Run owns a configured client-manager workspace below its data root. Each installation receives stable internal `active`, `previous`, and `staging/<deployment-generation>` slots. Artifact bytes are downloaded on the artifact channel with offset/checksum resume metadata, extracted with traversal/link checks, and verified against the Platform checksum before activation. The executable/config paths are resolved from the approved profile/package contract, never from the API caller. Activation uses an atomic rename where supported; the previous slot is kept for one bounded rollback generation.
Uninstall stops the supervised process, revokes/forgets the local component session material, and removes only the installation's controlled slots and journal entry. It never follows symlinks or deletes server/shared roots. History and audits remain in Platform.
Alternative: unpack directly over the active files. Rejected because cancellation, partial download, checksum failure, and rollback would leave an indeterminate executable.
### Decision 4: Client Manager has a separate signed identity and session
The generated package retains the current client-manager component key and generation. Initial registration signs a canonical request with that key, timestamp, and nonce. Platform resolves the same-server/component key, verifies generation and ownership, rejects expired timestamps or replayed nonces, and checks installed artifact version/revision/capabilities against the active deployment. Successful registration creates a randomly generated short-lived Client Manager session, persists only its hash and metadata, and returns the bearer token only to the Client Manager process.
Heartbeat and capability reports use that component session, not the Run control session or Run job lease. Sessions are bound to installation ID, server ID, profile, key generation, deployment generation, and active artifact. Reset, explicit revoke, update activation, rollback, uninstall, ownership/endpoint reassignment, or expiry revokes the session. A new deployment must register again. Platform stores bounded replay nonces and prunes them after the signature window.
Alternative: let Run proxy its own Platform session for the child process. Rejected because it would let a client-manager compromise inherit Run's broader machine authority and would couple component health to Run control traffic.
### Decision 5: Supervision and health are declarative and bounded
Profiles declare a fixed executable relative path, fixed argument keys/placeholders, startup timeout, stop timeout, health mode, health interval/timeout, required capability names, and whether start/restart/update/rollback are allowed. Validation rejects shell metacharacters, absolute/traversing paths, environment secrets, raw sockets, and unknown capabilities. Run launches only the approved relative executable from the active slot, captures bounded diagnostics, and reports logical process/health states without PIDs or paths.
Health can be process-presence or a bounded component self-report contract. Platform uses signed component heartbeats as the authoritative online signal, with Run process state as deployment/control evidence. Missing heartbeats transition online to degraded and then offline according to profile bounds.
Alternative: accept plugin-provided shell start/health commands. Rejected because it creates an unrestricted execution and data-exfiltration path.
### Decision 6: Updates are single-installation staged transactions
An update requires an available current-generation artifact with the same server/profile/target, a compatible declared version/revision, explicit operator approval, and a healthy installed baseline unless force recovery is explicitly allowed. Run downloads and verifies the new artifact in a staging slot, stops the old process only at activation, swaps slots, starts the candidate, and waits for bounded process/component health. Success commits the active/previous references; failure automatically restores the previous slot and reports `rolling_back` followed by the real result.
Platform rejects revoked artifacts, stale key/deployment generations, cross-target or cross-owner artifacts, and replayed update requests. Restart reconciliation resumes from the durable phase or safely rolls back; it never marks a later phase complete based on a timer.
Alternative: overwrite and restart immediately. Rejected because it cannot prove health or recover from a broken package.
### Decision 7: API and UI expose a safe action projection
Platform provides installation summary/detail, deploy, control, update, rollback, session revoke, retry, and uninstall APIs plus component-only register/heartbeat endpoints. Operator endpoints require the existing session/role/server authorization; component endpoints use the separate signature/session authenticator. Action availability is computed from installed plugin declarations, runtime binding completeness, server ownership, endpoint online/capabilities, distribution state/ownership/target/key generation, installation state, and current user permission.
platform_web renders a Client Manager operations section on Server Detail and compact availability in server actions. It shows profile, target, desired/active/previous versions, artifact and job IDs, deployment generation, safe health/last-seen reason, build/deploy/register/control/update/rollback/uninstall phases, retry guidance, and destructive confirmations. It never renders a raw key, token, secret ref/value, path, PID, socket, credential, endpoint address, DSN, or RCON password.
### Decision 8: Auditing and channel isolation are first-class invariants
Every build/deploy/register/start/stop/restart/update/rollback/revoke/uninstall success, failure, and denial records a durable audit with actor type, safe actor ID, server, profile/component, installation/job/artifact IDs, result, and redacted reason. Component heartbeats are summarized as health state rather than producing an unbounded audit event per pulse.
Artifact download remains resumable and lower priority; Run control heartbeat, job ack/result/cancel, log spool upload, and optional client-manager traffic use independent workers/queues. Channel-isolation tests exercise a stalled client-manager download and prove the other channels progress.
## Risks / Trade-offs
- [Risk] A malicious or compromised source repository can still produce a hostile binary. → Continue requiring approved HTTPS repositories, pinned revisions, fixed build adapters, isolated build workspaces, bounded logs, and explicit operator deployment; do not claim public untrusted builds are production safe.
- [Risk] Atomic rename and executable replacement differ across operating systems. → Keep platform-neutral slot semantics, isolate OS-specific activation in Run, retain the prior slot, and fail without changing active state when atomic activation is unavailable.
- [Risk] Platform and Run can observe different phases during network loss. → Persist intent before dispatch, use idempotency/deployment generations, reconcile job/session/journal state, and favor safe `degraded`/`failed` projections over inferred success.
- [Risk] Key reset immediately invalidates an online component. → Revoke sessions and old distributions, mark the installation as requiring a current-generation rebuild/redeploy, explain recovery in UI, and never silently rotate a package secret.
- [Risk] Heartbeat writes and nonces can grow storage. → Store bounded summaries, unique nonce digests within a short verification window, and prune expired sessions/nonces during reconciliation.
- [Risk] Safe extraction and cleanup are security-sensitive. → Reject traversal, links, device files, unexpected package layouts, and any deletion outside the configured client-manager workspace; cover these cases with tests.
## Migration Plan
1. Extend and validate plugin profile declarations without changing existing installed profile records; profiles lacking the new deployment contract remain build/download-only and lifecycle actions are unavailable with a safe reason.
2. Add Platform models/repositories and initialize installation/session/nonce state without mutating existing distributions or component keys.
3. Add typed Platform APIs/jobs/reconciliation and independent component authentication behind capability gating.
4. Add Run protocol/runtime support, controlled workspace/journal, supervisor, update/rollback, and uninstall safety.
5. Enable the full profile for the first-party SCUM example and add platform_web lifecycle management only when the API projection advertises actions.
6. Verify both repositories and all consumers. Rollback hides new actions and stops dispatching lifecycle jobs; existing distribution download remains available and durable lifecycle/audit history is retained.
## Open Questions
- Production code-signing, KMS-backed component keys, private repository credentials, multi-node fleet rollout, and long-term deployment artifact retention remain explicit follow-up work.
- The first implementation supports one active installation per server/profile and one retained previous slot; multi-instance client-manager replicas require a later contract.
@@ -0,0 +1,32 @@
## Why
Client-manager support currently ends after a real package is built and downloaded: Platform does not durably deploy, register, supervise, update, roll back, revoke, or uninstall the companion process. Operators therefore cannot complete a secure build-to-online-to-retired lifecycle or distinguish real machine state from package availability.
## What Changes
- Extend plugin client-manager profiles with version/revision, deployment mode, required capabilities, health contract, lifecycle actions, compatibility constraints, and update policy while continuing to reject arbitrary commands and secret-bearing declarations.
- Add a durable Platform client-manager installation aggregate and state machine covering request, build, availability, deployment, installation, registration, online health, degradation/offline detection, update/rollback, stop, failure, revocation, and uninstall history.
- Add typed `client-manager.deploy`, lifecycle control, update, rollback, and uninstall jobs that only an authorized, online, capable Run endpoint may execute for a same-server, same-component, target-compatible, current-generation available artifact.
- Add a client-manager component identity, signed registration/session/heartbeat protocol, capability fencing, expiry/replay protection, and revocation that is separate from Run control registration, jobs, leases, and credentials.
- Add Run-side staged/resumable artifact deployment, checksum verification, atomic activation, bounded process supervision, durable local journals, reconciliation, retry/cancel/idempotency fences, health checks, update rollback, and safe uninstall of controlled workspaces only.
- Add server-list/detail Client Manager management workflows for version/build/deployment/registration/health, start/stop/restart, update/rollback, key reset recovery, failure retry, revoke, and uninstall, backed by real job progress and confirmation flows.
- Add durable audit and safe status projections for every sensitive operation while preventing plugins and platform_web from receiving raw keys, sessions, secret refs/values, host paths, PIDs, sockets, credentials, or direct Run endpoint details.
- Preserve independent control, job, log, artifact, and optional client-manager bridge channels so client downloads and traffic cannot block Run heartbeat, job results, or log upload.
## Capabilities
### New Capabilities
- `secure-client-manager-lifecycle`: Secure deployment, component identity, durable state, health, bounded control, update/rollback, revocation, uninstall, reconciliation, auditing, and operator workflows for plugin-declared client managers.
### Modified Capabilities
- `run-distribution-and-client-managers`: Client-manager build artifacts become inputs to a real deployment lifecycle, and key reset/revocation must fence installed instances and require a current-generation redeploy.
## Impact
- `plugins/`: manifest schema, SDK/bridge contracts, SCUM and Minecraft examples, unsafe fixtures, validation, and documentation.
- `platform/`: domain/model/repository state, validators, signed component-session protocol, job orchestration, reconciliation, audit, DTOs, API routes, authorization, and documentation.
- Independent `run/` repository: protocol contracts, artifact deployment, local journal/workspace safety, process supervision, health reporting, lifecycle execution, update/rollback, and channel-isolation tests.
- `platform_web/`: API types/schemas/client, Server Detail Client Manager workspace, server action availability, status/progress/confirmation/error states, tests, and browser acceptance.
- No billing, cloud-host/provider marketplace, arbitrary shell, general remote control, production KMS/code-signing, or fleet-orchestration claim is introduced.
@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: Run and client-manager keys are isolated singletons
Run executors and plugin-declared client managers SHALL use different authentication secrets, and each server/component SHALL have exactly one current active key stored encrypted in the platform database. A deployed Client Manager SHALL exchange proof of its current component key for a separate short-lived component session and SHALL never use a Run control session or job lease.
#### Scenario: Client manager is generated after run
- **WHEN** a plugin-declared client-manager package is generated for a server that already has a run package
- **THEN** platform MUST create or reuse the server's current encrypted client-manager key and MUST NOT reuse, reveal through API metadata, or derive it from the run key
#### Scenario: Component key reset is requested
- **WHEN** an operator resets a server's run key or client-manager key
- **THEN** platform MUST replace the encrypted database key for that component, increment the key generation, revoke all packages generated with prior generations, revoke matching component sessions and installed deployment fences, mark the affected installation as requiring current-generation rebuild and redeploy, and record an audit event identifying the component kind without logging raw key material
#### Scenario: Old package authenticates after reset
- **WHEN** a run or client-manager package generated before the latest key reset attempts to authenticate, register, heartbeat, deploy, or execute lifecycle work
- **THEN** platform MUST reject the old key, session, artifact, or generation and require the operator to regenerate and redeploy the corresponding run or client-manager package
### Requirement: Client-manager packages are plugin-declared builds
The platform SHALL support plugin-declared client-manager build profiles for companion executables that require source checkout, configuration injection, and compilation before download or secure lifecycle deployment. Only a real available build artifact with a current component-key generation SHALL be eligible for deployment.
#### Scenario: SCUM-style client manager is generated
- **WHEN** a plugin declares a client-manager build profile with repository, revision policy, supported target platform, build system, config template, output artifact paths, deployment contract, lifecycle capabilities, health contract, compatibility constraints, and update policy
- **THEN** platform MUST create a run-worker build job that checks out the approved source and revision, injects configuration obtained through the authenticated job-input channel, compiles the target executable, uploads the downloadable artifact through the artifact channel, records deployable version/target/key-generation metadata, and redacts secrets and workspace paths from progress and build results
#### Scenario: Client-manager build is still running
- **WHEN** the source checkout, environment check, dependency download, compile, artifact upload, or publication stage is incomplete
- **THEN** platform_web MUST display the corresponding real job progress and MUST NOT mark later build or deployment stages complete on a local timer
#### Scenario: Unsupported client-manager target is requested
- **WHEN** an operator requests a client-manager build for an OS/architecture not declared by the plugin profile
- **THEN** platform MUST reject the request before cloning source or creating a credential
#### Scenario: Built artifact is selected for deployment
- **WHEN** an operator selects a client-manager distribution for lifecycle deployment
- **THEN** platform MUST require status available, current key generation, matching server/profile/component/target, approved revision and compatibility metadata, authorized server access, complete runtime binding, and an online assigned Run endpoint with the declared deployment capabilities before creating a typed deploy job
@@ -0,0 +1,153 @@
## ADDED Requirements
### Requirement: Plugins declare bounded client-manager lifecycle contracts
Game plugins SHALL declare client-manager version/revision metadata, supported targets, deployment mode, required Run and component capabilities, relative executable contract, bounded lifecycle actions, health contract, compatibility constraints, and update policy before Platform enables lifecycle operations.
#### Scenario: Valid lifecycle profile is installed
- **WHEN** a plugin declares a client-manager profile with a supported target, pinned or policy-approved revision, fixed build adapter, safe relative executable, bounded start/stop/restart and health settings, and known capability names
- **THEN** plugin and Platform validation MUST preserve the declaration and Platform MUST derive lifecycle availability from the installed declaration, runtime binding, server ownership, and assigned endpoint capabilities
#### Scenario: Unsafe lifecycle profile is submitted
- **WHEN** a plugin declaration contains arbitrary shell, an absolute or traversing path, raw credentials, secret or token values, direct sockets, host endpoints, environment secrets, unknown capabilities, or an unbounded health/control action
- **THEN** plugin and Platform validation MUST reject it before registration or lifecycle dispatch
### Requirement: Platform gates every lifecycle action against current ownership and capability state
Platform SHALL authorize client-manager build, deploy, register, control, update, rollback, revoke, retry, and uninstall independently using the current actor, server visibility, installed plugin declaration, runtime binding, assigned Run endpoint, artifact ownership, target, revision, component key generation, and lifecycle state.
#### Scenario: Authorized owner deploys an available build
- **WHEN** a server owner or authorized administrator selects an available current-generation distribution for the same server, profile, target, and approved revision while the assigned Run endpoint is online and declares client-manager deployment capability
- **THEN** Platform MUST create or reuse one typed deployment intent and job and MUST expose its real state and progress
#### Scenario: Mismatched lifecycle input is requested
- **WHEN** an actor supplies another owner's server, another server or component artifact, another Run endpoint, a mismatched target or revision, an expired or revoked distribution, or a stale component-key generation
- **THEN** Platform MUST deny the operation before job creation, MUST record a redacted denial audit, and MUST NOT reveal whether an inaccessible resource exists
#### Scenario: Service or component credential calls an operator endpoint
- **WHEN** a Run service credential or Client Manager component session calls an operator lifecycle endpoint without the required operator role
- **THEN** Platform MUST return an authorization failure and MUST NOT broaden that credential into an operator session
### Requirement: Platform persists and reconciles a real lifecycle state machine
Platform SHALL durably persist desired state, active and previous deployment references, component-key and deployment generations, job linkage, health summary, failure detail, and lifecycle timestamps for each server/profile installation.
#### Scenario: Lifecycle advances through real evidence
- **WHEN** build, deployment, registration, control, update, rollback, or uninstall work changes phase
- **THEN** Platform MUST transition only through valid requested, building, available, deploying, installed, registering, online, degraded, offline, updating, rolling_back, stopping, uninstalled, or failed states using durable job results, Run reports, or authenticated component heartbeats rather than local UI timers
#### Scenario: Platform restarts with in-flight work
- **WHEN** Platform restarts while a lifecycle job or component session is in progress
- **THEN** reconciliation MUST restore the persisted intent, project the durable job/session state idempotently, reject stale attempts, and either resume, retry, roll back, or fail safely without creating a duplicate activation
#### Scenario: Duplicate lifecycle request is retried
- **WHEN** the same actor repeats a request with the same idempotency key and immutable inputs
- **THEN** Platform MUST return the original installation/job result, while the same idempotency key with different immutable inputs MUST be rejected
### Requirement: Run deploys client managers through a checksummed controlled workspace
Run SHALL execute client-manager deployment only through the typed job contract and SHALL download, resume, verify, stage, and atomically activate an authorized distribution inside its configured client-manager workspace.
#### Scenario: Deployment completes after an interrupted transfer
- **WHEN** a current leased deployment downloads an available artifact in chunks and the transfer is interrupted
- **THEN** Run MUST persist offset and checksum state, resume without re-downloading acknowledged bytes, verify the final checksum and safe package layout, activate the staged slot, and report installed only after real activation succeeds
#### Scenario: Deployment payload or package is unsafe
- **WHEN** a deployment contains arbitrary commands, raw host paths, sockets, credentials, a stale attempt/deployment/key generation, a mismatched artifact/target/component, a checksum failure, traversal, symlink, device file, or unexpected executable layout
- **THEN** Run MUST reject or fail the job without changing the active slot and MUST return only bounded redacted diagnostics
#### Scenario: Deployment is cancelled or retried
- **WHEN** cancellation arrives between chunks or activation phases, a lease expires, or a retry uses the same idempotency and deployment generation
- **THEN** Run MUST honor the current fence, retain only safe resumable staging state, never let a stale attempt replace a newer activation, and converge on one recorded outcome
### Requirement: Client Manager authenticates as an independent component
Client Manager SHALL register with Platform using its own current component key and generation and SHALL receive a short-lived component session that is separate from Run control registration, job leases, credentials, and channels.
#### Scenario: Installed component registers successfully
- **WHEN** a deployed Client Manager signs a canonical registration request with a fresh timestamp and nonce and reports the active installation, artifact, version/revision, deployment generation, and declared capabilities
- **THEN** Platform MUST verify the same server/profile ownership, current component-key generation, active deployment, target/revision, signature, nonce, and capabilities, persist only a hash of a new expiring component session, return the token only to the component, and move the installation toward online health
#### Scenario: Registration signature is stale, replayed, revoked, or mismatched
- **WHEN** registration uses an expired timestamp, repeated nonce, revoked or previous-generation key, another server/component identity, inactive artifact, stale deployment generation, or undeclared capabilities
- **THEN** Platform MUST reject registration, record a safe denial audit, and MUST NOT create or reveal a component session
#### Scenario: Run identity is presented as Client Manager identity
- **WHEN** a caller presents a Run key, Run bearer session, Run job lease, or Run endpoint identity to the Client Manager registration or heartbeat contract
- **THEN** Platform MUST reject it and MUST NOT reuse Run authentication state
### Requirement: Component heartbeats drive safe health projection
Platform SHALL accept bounded heartbeat and capability reports only from a valid component session and SHALL project logical health and last-seen state without exposing local process details.
#### Scenario: Healthy component heartbeat arrives
- **WHEN** an unexpired, unrevoked session bound to the active installation reports a monotonic heartbeat with declared capabilities and a safe health code
- **THEN** Platform MUST update last seen and logical health idempotently and MUST expose only version, status, health code/reason, capabilities, and timestamps to authorized operators
#### Scenario: Heartbeat expires
- **WHEN** a component misses its declared heartbeat grace and offline thresholds
- **THEN** reconciliation MUST transition the installation from online to degraded and then offline using safe reasons while preserving the last successful deployment and audit history
#### Scenario: Session heartbeat is replayed or fenced
- **WHEN** a heartbeat sequence repeats, the session is expired/revoked, or its key, deployment, endpoint ownership, or artifact fence is no longer current
- **THEN** Platform MUST reject it without mutating health and require a new valid registration
### Requirement: Run performs bounded client-manager process control
Run SHALL start, stop, restart, and inspect a deployed Client Manager only through the plugin-declared executable and health contract and the typed lifecycle job.
#### Scenario: Operator starts or restarts a deployed component
- **WHEN** Platform dispatches an authorized current-generation control job whose action is declared by the profile
- **THEN** Run MUST supervise the fixed relative executable from the active slot, use bounded timeouts, persist the logical process state, and report progress and outcome without returning a PID, host path, environment secret, or socket
#### Scenario: Unsupported or stale control is requested
- **WHEN** a control action is undeclared, the installation is uninstalled, the attempt or deployment generation is stale, or another process already owns the active fence
- **THEN** Run MUST reject the operation idempotently without executing a command or disrupting the newer process
### Requirement: Updates are staged, health-checked, and rollback-safe
Platform and Run SHALL treat a Client Manager update as a same-installation transaction with explicit approval, compatible current-generation artifact selection, staged activation, bounded health confirmation, and a retained previous deployment.
#### Scenario: Compatible update becomes healthy
- **WHEN** an authorized operator approves a newer compatible artifact for the same server/profile/target and Run verifies, stages, activates, starts, and observes required health
- **THEN** Platform MUST set the new artifact/version as active, retain the prior deployment as rollback candidate, revoke the superseded component session, require new registration, and record real update progress and audit evidence
#### Scenario: Candidate update fails health
- **WHEN** download, checksum, activation, startup, registration, or health confirmation fails after an update begins
- **THEN** Run MUST preserve or restore the previous slot, Platform MUST project rolling_back and the real rollback result, and success MUST NOT be reported unless the restored deployment is active and healthy
#### Scenario: Invalid update or rollback is requested
- **WHEN** an artifact is revoked, from another server/profile/target, has a stale key generation, violates compatibility/version policy, or the previous slot no longer exists
- **THEN** Platform and Run MUST reject the request before activation and preserve the current deployment
### Requirement: Revocation and uninstall are safe and idempotent
Platform SHALL support session revocation and Run SHALL stop and uninstall a Client Manager without deleting server or shared files, while retaining Platform lifecycle and audit history.
#### Scenario: Key or session is revoked
- **WHEN** an authorized operator resets the component key, explicitly revokes the component session, reassigns ownership/endpoint, activates an update/rollback, or begins uninstall
- **THEN** Platform MUST revoke matching sessions, reject subsequent heartbeats, fence old artifacts/deployments as applicable, and show that rebuild/redeploy or registration is required
#### Scenario: Installed component is uninstalled
- **WHEN** an authorized operator confirms uninstall and Run completes the typed job
- **THEN** Run MUST stop the supervised process, remove only controlled active/previous/staging slots and local session/journal material for that installation, Platform MUST mark it uninstalled, and build/distribution/audit history MUST remain available
#### Scenario: Uninstall is repeated or interrupted
- **WHEN** uninstall is retried after partial cleanup, cancellation, lease expiry, or an already-uninstalled result
- **THEN** Run and Platform MUST converge idempotently without following links, escaping the configured workspace, or deleting game server/shared data
### Requirement: Lifecycle operations are durably audited and redacted
Platform SHALL record durable success, failure, and denial audits for build, deploy, register, start, stop, restart, update, rollback, revoke, retry, and uninstall using safe identifiers and bounded reasons.
#### Scenario: Lifecycle result is audited
- **WHEN** an operator, Run endpoint, or Client Manager performs or is denied a sensitive lifecycle action
- **THEN** the audit MUST include actor type and safe actor ID, server/profile/component, installation, job or artifact ID where applicable, operation, result, and redacted reason without raw keys, tokens, secret refs/values, credentials, paths, PIDs, sockets, endpoint addresses, DSNs, RCON passwords, or large output
### Requirement: Client Manager traffic remains isolated from Run channels
Client-manager registration, heartbeat, deployment transfer, process control, and optional game-client traffic SHALL remain separated from Run control heartbeat, job acknowledgement/result/cancel, log ingest, and artifact upload scheduling.
#### Scenario: Client-manager artifact transfer stalls
- **WHEN** a large or stalled client-manager download or component traffic stream is active
- **THEN** Run heartbeat, job ack/result/cancel polling, log spool upload, and unrelated artifact progress MUST continue independently within their bounded queues
### Requirement: Platform web provides a complete safe Client Manager workspace
platform_web SHALL provide authorized operators a Client Manager management workspace that reflects real backend state and preserves the existing black-mecha and magical-girl crystal-moonlight game-operations visual system.
#### Scenario: Operator manages the complete lifecycle
- **WHEN** an authorized operator opens Server Detail for a declared Client Manager
- **THEN** the UI MUST show safe profile/target/version/revision, build and artifact state, deployment/registration/online health, last seen, active/previous deployment, current job progress, permitted start/stop/restart, update/rollback, retry/redeploy after key reset, session revoke, and confirmed uninstall actions using real API projections
#### Scenario: Action is unavailable or destructive
- **WHEN** an action lacks permission, declaration, binding, online endpoint, capability, compatible artifact, current key generation, allowed lifecycle state, or confirmation
- **THEN** the UI MUST disable or hide it with a safe reason, require explicit confirmation for key reset/revoke/rollback/uninstall, preserve 401/403 handling, and MUST NOT fabricate progress or expose raw secrets, sessions, paths, PIDs, sockets, credentials, or endpoint addresses
@@ -0,0 +1,58 @@
## 1. Plugin Lifecycle Contracts
- [x] 1.1 Extend client-manager manifest and SDK types with safe version/revision, deployment, executable, lifecycle capability, health, compatibility, and update-policy declarations.
- [x] 1.2 Validate bounded relative executables, target/capability enums, timeouts, version rules, and reject arbitrary shell, traversal, raw secrets, endpoints, sockets, and credential-bearing declarations.
- [x] 1.3 Update SCUM and Minecraft example profiles, SDK bridge contracts, docs, and unsafe fixtures for complete lifecycle declarations and safe lifecycle requests/status.
- [x] 1.4 Add plugin manifest, SDK, and bridge tests covering accepted contracts, unsafe declarations, typed operations, and redaction.
## 2. Platform Durable Lifecycle Model
- [x] 2.1 Add domain/model/DTO types for installation states, action availability, deployment slots, health, desired/active/previous versions, lifecycle requests/results, component sessions, and replay nonces.
- [x] 2.2 Extend repository interfaces plus file and MySQL stores with durable client-manager installations, sessions, nonce fences, idempotent lookups, list/update/revoke operations, and safe persistence tests.
- [x] 2.3 Add validators for lifecycle transitions, action/target/version compatibility, deployment/control/update/rollback/uninstall inputs, component registration, heartbeat sequences, and redacted bounded results.
- [x] 2.4 Implement installation state transitions and terminal job projection using real durable job evidence, including idempotency, stale attempt/deployment/key fences, retryable failure, and active/previous deployment commits.
- [x] 2.5 Implement startup/periodic reconciliation for in-flight lifecycle jobs, expired sessions/nonces, heartbeat degraded/offline thresholds, restart recovery, and key-reset/endpoint-reassignment fencing.
## 3. Independent Component Identity
- [x] 3.1 Add canonical Client Manager registration signature and session contracts that are separate from Run control registration and job leases.
- [x] 3.2 Implement current component-key/generation signature verification, timestamp/nonce replay protection, ownership/artifact/target/revision/deployment/capability checks, hashed expiring session issuance, and denied audits.
- [x] 3.3 Implement component-session heartbeat authentication, monotonic sequence fencing, safe health projection, expiry/revocation, and explicit session revoke behavior.
- [x] 3.4 Add registration/session/heartbeat tests for current identity plus cross-owner/server/component/artifact/target/revision/key generation, expired, revoked, replayed, and Run-credential rejection paths.
## 4. Platform Job Orchestration and APIs
- [x] 4.1 Add typed client-manager deploy, control, update, rollback, and uninstall job kinds, payload validation, endpoint capability declarations, and safe job progress/result projection.
- [x] 4.2 Implement service authorization and action gating across actor role, server visibility, installed plugin/profile, runtime binding, endpoint online/capabilities, distribution availability/ownership/target/revision/key generation, and lifecycle state.
- [x] 4.3 Implement deploy/control/update/rollback/retry/revoke/uninstall services with durable intent-before-dispatch, idempotency, cancel/retry/stale-attempt handling, session fencing, and audit events.
- [x] 4.4 Add operator lifecycle summary/detail/action routes and component register/heartbeat routes with named DTOs, OpenAPI-style comments, session separation, safe errors, and API documentation.
- [x] 4.5 Add platform service/API tests covering owner/admin/service auth, 401/403, build-to-deploy, cross-boundary denial, restart reconcile, cancellation/retry/idempotency, health timeout, update rollback, key reset recovery, uninstall safety, auditing, and redaction.
## 5. Run Deployment and Supervision
- [ ] 5.1 Add Run protocol payloads and validation for client-manager deploy/control/update/rollback/uninstall, immutable fences, lifecycle progress/results, and endpoint capabilities.
- [ ] 5.2 Add a scoped client-manager workspace and durable local journal for installation slots, chunk offsets/checksums, deployment/attempt/key generations, idempotency outcomes, process state, and restart reconciliation.
- [ ] 5.3 Implement resumable artifact download, checksum verification, safe archive extraction, staging, atomic active/previous activation, cancellation checkpoints, and rejection of traversal/symlinks/device files/unexpected layouts.
- [ ] 5.4 Implement bounded declarative client-manager start/stop/restart/status supervision with fixed relative executable, safe timeouts, logical health, and no path/PID/socket projection.
- [ ] 5.5 Implement staged update health confirmation, automatic rollback, explicit rollback, stale/revoked generation rejection, re-entry after restart, and real phase reporting.
- [ ] 5.6 Implement idempotent safe uninstall that stops the process and deletes only controlled installation slots/journal/session material without following links or touching server/shared files.
- [ ] 5.7 Add Run tests for deploy resume/checksum, fences, cancel/retry/stale attempts, reconciliation, supervision, health failure rollback, uninstall safety, redaction, and channel isolation under stalled client-manager traffic.
## 6. platform_web Lifecycle Workspace
- [ ] 6.1 Add API types, schemas, client methods, safe action projections, polling/job progress integration, and tests for Client Manager lifecycle summaries and commands.
- [ ] 6.2 Build a rich Server Detail Client Manager workspace showing build/artifact, desired/active/previous version, deployment/registration/online health, last seen, real current job phases, retry guidance, and action availability.
- [ ] 6.3 Add start/stop/restart, deploy/redeploy, update/rollback, session revoke, key reset recovery, retry, and uninstall confirmation/error flows while preserving compact server action menus and both existing themes.
- [ ] 6.4 Add frontend tests and browser acceptance for full state/action coverage, real progress/failure recovery, destructive confirmations, 401/403 behavior, responsive layouts, theme preservation, and secret/path/PID/socket redaction.
## 7. Documentation and Verification
- [ ] 7.1 Update Platform, Run, plugins, SDK, platform_web, route, domain, protocol, and deployment docs with lifecycle states, security/session boundaries, operations, recovery, and explicit production non-goals.
- [ ] 7.2 Run plugin manifest validation, plugin SDK/tests/typecheck, Platform `go test -count=1 ./...`, independent Run `go test -count=1 ./...`, and focused race/restart checks where practical.
- [ ] 7.3 Run platform_web tests, typecheck, production build, and a browser walkthrough for the touched Server Detail and server action workflows in both visual themes.
- [ ] 7.4 Run shell/compose checks, `scripts/check-structure.sh`, and `git diff --check` in both the main repository and independent Run checkout.
- [ ] 7.5 Run `openspec validate implement-secure-client-manager-lifecycle --strict` and record all verification evidence below before marking implementation complete.
## Verification Evidence
Pending implementation and verification.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-17
@@ -0,0 +1,75 @@
## Context
The plugin JSON schema and TypeScript SDK already define discovery probes, lifecycle profiles, dependency probes/install plans, log sources, transports, and client-manager profiles. Platform manifest DTOs do not decode `runtimeProfiles`, `GamePlugin` does not store them, and durable snapshots omit `RuntimeBinding`, so those declarations and bindings disappear before they can drive a server workflow. The existing action gate also reports complete when no binding rows exist.
The platform uses typed domain/model records behind repository interfaces. File and MySQL backends durably serialize the same `StoreSnapshot`, while tests use `MemoryStore`. The web console consumes platform-owned safe DTOs and must never receive raw host paths, direct sockets, credentials, or secret storage values.
## Goals / Non-Goals
**Goals:**
- Preserve every supported safe runtime-profile declaration during manifest registration and durable reload.
- Select exactly one declared lifecycle profile per server runtime binding and derive its required logical keys deterministically.
- Create or update bindings through owner/admin-authorized APIs, persist them in every durable store, and expose only redacted readiness metadata.
- Require a complete binding before lifecycle and runtime actions whose execution depends on the selected profile.
- Make profile selection and binding completion available in the create-server and server-detail workflows.
**Non-Goals:**
- Storing raw credentials, direct sockets, or host filesystem paths in platform metadata.
- Implementing a general secret vault, run-side path resolver, durable scheduler, process supervisor, log/artifact backend, dependency installer, self-update system, or client-manager deployment lifecycle.
- Changing the independent run repository or declaring the wider production-readiness roadmap complete.
## Decisions
### Decision 1: Persist typed profiles on the installed plugin record
Platform will mirror the existing manifest/SDK runtime profile structures in domain, DTO, and model packages and copy them into `GamePlugin` at registration. File/MySQL snapshots already persist plugin records, so this preserves the immutable installed-version contract without reaching across repository roots or storing arbitrary manifest JSON.
Alternative considered: keep only the manifest artifact reference and re-read the artifact for every request. This was rejected because artifact availability is a separate lifecycle, it makes validation/reload behavior non-deterministic, and it leaves action gating dependent on external content.
### Decision 2: One server-scoped binding aggregate selects one lifecycle profile
Each server has one `RuntimeBinding` identified deterministically from its server ID. It records the plugin ID/version contract, selected lifecycle `profileKey`, profile mode, logical binding refs, derived missing keys, and readiness status. Required keys are derived from the selected lifecycle profile and the referenced discovery, dependency, log, transport, and client-manager declarations; callers cannot self-assert `complete` or `missingKeys`.
Alternative considered: one row per logical key. This was rejected for now because profile changes need atomic validation and readiness projection, while the existing repository abstraction has aggregate create/update semantics.
### Decision 3: Accept safe opaque values, return redacted metadata
Write requests accept logical references and `secret://` references only. Values containing raw absolute paths, URI sockets/DSNs, inline credentials, traversal, or other unsafe material are rejected. Read responses return each logical key with a `configured` boolean and `secret` boolean, never the stored value or internal secret-storage location. Missing reasons name only declared logical keys.
Alternative considered: return stored logical refs directly. This was rejected because even non-secret refs can encode topology or storage details and the browser does not need them to review readiness.
### Decision 4: Create workflow persists the binding before dispatch
Server creation requires a declared profile key and optional initial binding values. The service validates the plugin/profile, creates the server and its binding, verifies completeness, and only then queues install. If required keys are missing, the request fails with logical missing-key details and no install job is dispatched. Repository rollback is limited by the current non-transactional abstraction; validation is therefore completed before the first write, and a binding persistence failure prevents dispatch and is surfaced explicitly.
Existing stored servers without a binding remain readable but are action-gated with a safe `runtime profile is not configured` reason until an owner or platform admin configures one.
### Decision 5: Authorization reuses server ownership rules
Listing a binding uses server visibility; changing it requires server ownership or platform-admin authority. Plugin pages receive no direct binding mutation surface. Lifecycle services independently check binding readiness so bypassing the UI or runtime-action projection cannot dispatch work.
### Decision 6: Web forms use declared contract data
`GamePluginResponse` exposes safe runtime-profile declarations required for selection and labels. The create form renders the selected plugin's lifecycle profiles and declared logical keys, submits the real profile and bindings, and avoids path/socket/credential terminology. The server detail view loads the redacted binding, supports profile changes and logical-key updates, and shows safe missing reasons.
## Risks / Trade-offs
- [Snapshot writes are aggregate and not transactional across server and binding repositories] -> Perform all validation before writes, persist the binding before job dispatch, and add failure/reload tests; a later durable-job change can introduce transactions.
- [Profile key derivation can over-require unrelated declarations] -> Scope derivation to the selected lifecycle profile and directly referenced transports/client manager, plus required global discovery/dependency/log targets.
- [Opaque safe refs cannot prove run-side resolvability] -> Treat platform completeness as contract completeness only; run-side resolution/health remains a later lifecycle responsibility.
- [Existing servers become gated after upgrade] -> Keep them readable and return a safe configuration-required reason; operators can select a profile in server detail.
- [Changing an active server profile could invalidate running work] -> Reject binding updates while the server is installing or running; require a stable non-active state.
## Migration Plan
1. Deploy profile-aware decoding and snapshot fields with backward-compatible empty defaults.
2. Existing plugin records without persisted profiles remain listable but cannot configure a runtime binding until the manifest is re-registered.
3. Existing servers without bindings remain visible with lifecycle/runtime actions disabled.
4. Re-register manifests, then configure each server binding through the authorized detail workflow.
5. Rollback can ignore the additive JSON fields; no raw secret values are introduced by this change.
## Open Questions
- Transactional multi-resource creation and encrypted secret material persistence are deferred to the next security/persistence task rather than being represented as complete here.
@@ -0,0 +1,28 @@
## Why
Plugin manifests already describe runtime profiles, but platform registration discards those declarations and runtime bindings live only in an in-memory repository that is absent from durable snapshots. As a result, server creation cannot select a real profile or persist its logical bindings, and action gating incorrectly treats a server with no bindings as complete.
## What Changes
- Persist the complete safe runtime-profile contract from plugin manifest registration through domain, DTO, model, repositories, and durable file/MySQL snapshots.
- Add authorized server runtime-binding APIs and service operations for listing and updating one selected profile with validated logical values or secret references.
- Extend server creation to select a declared lifecycle profile and submit its initial logical bindings atomically with the instance workflow.
- Gate lifecycle and distribution actions on the selected profile and its required binding keys, returning only safe logical missing reasons.
- Add server creation and detail UI for choosing, reviewing, completing, and changing runtime bindings without displaying raw host paths, sockets, credentials, or secret storage details.
- Add plugin, platform, persistence, API, and frontend regression coverage for manifest projection, reload durability, invalid/missing binding rejection, action gating, and non-disclosure.
## Capabilities
### New Capabilities
- `runtime-profile-bindings`: Persist plugin-declared runtime profiles and provide server-scoped profile selection, logical binding management, secure projections, and action readiness.
### Modified Capabilities
## Impact
- `plugins/`: manifest/SDK validation and fixtures remain the source contract and gain persistence-oriented regression coverage where needed.
- `platform/`: runtime profile domain/DTO/model validation, store snapshots, server lifecycle creation, binding services/routes, authorization, and action gating.
- `platform_web/`: API contracts, create-server form, server-detail binding workflow, and focused tests.
- Public platform API requests and responses gain runtime profile and binding fields/routes; no raw machine location or credential data crosses into the web or plugin page boundary.
@@ -0,0 +1,86 @@
## ADDED Requirements
### Requirement: Platform persists plugin runtime profiles
The platform SHALL decode, validate, store, and return safe plugin runtime profiles covering server discovery, lifecycle, dependencies and install plans, log sources, transports, and client-manager declarations.
#### Scenario: Manifest registration survives reload
- **WHEN** an operator registers a valid plugin manifest with runtime profiles and the durable store is reopened
- **THEN** the installed plugin retains the same validated runtime-profile contract
#### Scenario: Unsafe runtime declaration is rejected
- **WHEN** a manifest runtime profile includes a raw host path, direct socket, credential, secret value, or arbitrary shell content
- **THEN** registration fails without persisting the unsafe declaration
### Requirement: Server selects a declared runtime profile
Each server runtime binding SHALL select a lifecycle profile declared by its installed plugin and SHALL derive required logical keys from that profile and its referenced runtime declarations.
#### Scenario: Valid profile selection
- **WHEN** an authorized operator selects a declared lifecycle profile for a server
- **THEN** the platform stores the server, plugin, profile, mode, derived required keys, and readiness state
#### Scenario: Undeclared profile is rejected
- **WHEN** a caller selects a profile key or logical binding key not declared by the server's plugin
- **THEN** the platform rejects the request without changing the stored binding
### Requirement: Runtime bindings are durable and authorized
The platform SHALL persist runtime bindings in memory, file, and MySQL-backed repository contracts and SHALL authorize server-scoped reads and owner/admin-scoped changes.
#### Scenario: Binding survives durable reload
- **WHEN** a valid binding is written through a durable store and the store is reopened
- **THEN** the selected profile and readiness metadata remain available for that server
#### Scenario: Unauthorized binding update
- **WHEN** a user who is neither platform admin nor server owner attempts to change a server binding
- **THEN** the platform denies the update and leaves the binding unchanged
### Requirement: Binding projections do not disclose runtime details
Runtime binding responses SHALL expose only profile metadata, declared logical keys, configured/secret flags, missing keys, status, and timestamps; they MUST NOT expose stored values, raw host paths, direct sockets, credentials, DSNs, or internal secret-storage locations.
#### Scenario: Secret reference is configured
- **WHEN** a stored logical binding uses a secret reference
- **THEN** the API reports that the logical key is configured and secret-backed without returning the reference value
#### Scenario: Missing logical binding is reviewed
- **WHEN** a required logical key is absent
- **THEN** the API and web console display the logical key and a safe configuration reason without storage details
### Requirement: Actions require a complete runtime binding
The platform SHALL gate lifecycle and runtime-dependent actions on the presence of a valid, complete runtime binding for the server's current plugin and selected profile.
#### Scenario: No binding is not complete
- **WHEN** a server has no runtime binding
- **THEN** lifecycle and runtime-dependent actions are disabled or rejected with a safe profile-not-configured reason
#### Scenario: Missing binding blocks dispatch
- **WHEN** a selected profile has one or more missing required logical keys
- **THEN** the platform does not dispatch the requested action and reports only the missing logical keys
#### Scenario: Complete binding permits normal validation
- **WHEN** the selected profile has all required logical keys configured
- **THEN** action handling proceeds to existing permission, endpoint capability, state, and idempotency checks
### Requirement: Server creation submits a real profile and bindings
The server creation workflow SHALL require a declared runtime profile and SHALL persist validated initial bindings before dispatching the install job.
#### Scenario: Complete create request
- **WHEN** an operator submits a server, declared profile, and all required logical bindings
- **THEN** the platform persists the server and binding and queues the install job with the selected profile context
#### Scenario: Incomplete create request
- **WHEN** a create request omits a required logical binding
- **THEN** the platform rejects creation before dispatch and identifies only the missing logical key
### Requirement: Operators can review and amend bindings
The management console SHALL derive profile choices and logical binding inputs from plugin declarations and SHALL provide a server-detail workflow to review or amend the selected profile and binding completeness.
#### Scenario: Create form submits selected contract
- **WHEN** an operator selects a plugin and profile and completes declared logical fields
- **THEN** the web client submits the actual profile key and binding map in the create workflow request
#### Scenario: Detail workflow updates bindings safely
- **WHEN** an authorized operator changes a stopped or draft server's profile or logical bindings
- **THEN** the console saves through the runtime-binding API and refreshes the redacted readiness projection
#### Scenario: UI does not render sensitive runtime data
- **WHEN** a binding response is rendered in create or detail workflows
- **THEN** the UI contains no secret value, raw path, socket, DSN, or internal storage reference
@@ -0,0 +1,32 @@
## 1. Runtime Profile Contract
- [x] 1.1 Add typed runtime profile declarations to platform domain, DTO, model, copy, and response projections.
- [x] 1.2 Validate profile keys, references, capabilities, safe strings, and cross-profile references during manifest registration.
- [x] 1.3 Persist registered runtime profiles through file/MySQL snapshots and prove reload behavior with tests.
## 2. Runtime Binding Persistence and API
- [x] 2.1 Add runtime binding snapshot persistence and repository reload coverage.
- [x] 2.2 Implement required logical-key derivation and binding validation against the selected plugin profile.
- [x] 2.3 Implement authorized list/update binding services and redacted DTO projections.
- [x] 2.4 Add documented server runtime-binding routes and API authorization/non-disclosure tests.
## 3. Lifecycle and Action Gating
- [x] 3.1 Extend server creation DTO/domain flow with a required profile key and initial logical bindings.
- [x] 3.2 Persist the binding before install dispatch and reject incomplete or undeclared create inputs.
- [x] 3.3 Gate start/stop and runtime-dependent actions on a present, current, complete binding with safe reasons.
- [x] 3.4 Add service/API regressions for missing/invalid bindings, successful dispatch, and reload survival.
## 4. Management Console
- [x] 4.1 Add frontend API types/client methods and form contracts for runtime profiles and redacted bindings.
- [x] 4.2 Connect plugin profile selection and declared logical binding inputs to the create-server workflow.
- [x] 4.3 Add a server-detail review/update workflow with safe missing reasons and no runtime value disclosure.
- [x] 4.4 Add frontend regressions proving real request submission, review/update behavior, and secret/path/socket non-disclosure.
## 5. Verification and Documentation
- [x] 5.1 Update platform, plugin, and web API/domain documentation for persisted profile and binding behavior without claiming later roadmap readiness.
- [x] 5.2 Run plugin manifest/SDK tests, platform Go tests, platform_web tests/typecheck/build, and risk-relevant run tests if run changes are required.
- [x] 5.3 Run `openspec validate persist-runtime-profiles-and-server-bindings --strict` and `scripts/check-structure.sh`, then record only evidence-backed completion.
+9
View File
@@ -18,3 +18,12 @@ PLATFORM_METADATA_PATH=.platform-data/metadata.json
# of server log lines into MySQL rows. # of server log lines into MySQL rows.
PLATFORM_LOG_BODY_BACKEND=file PLATFORM_LOG_BODY_BACKEND=file
PLATFORM_LOG_DIR=.platform-data/logs PLATFORM_LOG_DIR=.platform-data/logs
PLATFORM_ARTIFACT_DIR=.platform-data/artifacts
# Optional one-time bootstrap. Leave unset in normal deployments after an admin exists.
# The password is read from process environment and only a password verifier is persisted.
# PLATFORM_BOOTSTRAP_ADMIN_EMAIL=operator@example.test
# PLATFORM_BOOTSTRAP_ADMIN_PASSWORD=replace-with-a-long-local-secret
# Required outside disposable local development. This protects persisted component-key ciphertext.
# PLATFORM_SECRET_ENVELOPE_KEY=replace-with-at-least-32-random-characters
+7 -1
View File
@@ -50,6 +50,10 @@ Runtime configuration:
- `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`.
- `PLATFORM_LOG_BODY_BACKEND`: log body backend, default follows metadata backend except MySQL uses `file`; supported values are `file` and `memory`. - `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_LOG_DIR`: segmented log body directory, default `.platform-data/logs`.
- `PLATFORM_ARTIFACT_DIR`: private durable artifact body/transfer directory, default `.platform-data/artifacts`.
- `PLATFORM_BOOTSTRAP_ADMIN_EMAIL`: optional initial platform administrator email.
- `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD`: optional one-time bootstrap password; the platform applies no default and persists only a password verifier.
- `PLATFORM_SECRET_ENVELOPE_KEY`: external secret used to derive the AES-GCM component-key envelope key; use at least 32 random characters and keep it stable across restarts.
MySQL configuration example: MySQL configuration example:
@@ -73,4 +77,6 @@ The platform process automatically reads root `.env` and `platform/.env` before
For Docker, the root `docker-compose.yml` sets platform data under `/data/platform` and mounts it through the `platform-data` named volume. For Docker, the root `docker-compose.yml` sets platform data under `/data/platform` and mounts it through the `platform-data` named volume.
Current executable behavior includes the platform API, local auth/session support, durable file-backed metadata, segmented log bodies, run control/job/log/artifact routes, plugin bridge dispatch, and platform-mediated AI invocation. 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.
Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata. Server creation selects a declared profile, saves complete logical bindings before install dispatch, and existing lifecycle/runtime actions are gated when the binding is absent or incomplete. Browser and plugin-facing responses expose readiness only, not binding values. This change uses controlled 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, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks.
+75
View File
@@ -0,0 +1,75 @@
package api
import (
"net/http"
"strings"
"browser.local/platform/domain"
"browser.local/platform/service"
)
func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicAPIRequest(r) || runServiceRequest(r) {
next.ServeHTTP(w, r)
return
}
user, err := h.core.GetCurrentUser(bearerToken(r))
if err != nil {
writeServiceError(w, err)
return
}
if platformAdminRequest(r) && !apiPlatformAdmin(user) {
writeServiceError(w, service.ErrForbidden)
return
}
next.ServeHTTP(w, r)
})
}
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 r.Method != http.MethodGet {
return false
}
return path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") ||
path == "/api/v1/plugin-marketplace/plugins" || strings.HasPrefix(path, "/api/v1/plugin-marketplace/plugins/")
}
func runServiceRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/v1/run/control/") ||
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/")
}
func platformAdminRequest(r *http.Request) bool {
path := r.URL.Path
if path == "/api/v1/users/current" || strings.HasPrefix(path, "/api/v1/users/current/") {
return false
}
if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") ||
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
path == "/api/v1/metrics/platform" ||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") ||
path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") {
return true
}
if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) {
return true
}
return r.Method == http.MethodPost && (path == "/api/v1/jobs" || path == "/api/v1/artifacts" || path == "/api/v1/log-streams")
}
func apiPlatformAdmin(user domain.User) bool {
for _, role := range user.Roles {
if role == "platform-admin" || role == "admin" || role == "platformadmin" {
return true
}
}
return false
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if err := core.SeedLocalPlatformAdmin(); err != nil {
t.Fatalf("seed admin: %v", err)
}
for _, user := range []domain.User{
{ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
{ID: "user-other", DisplayName: "Other", Email: "other@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
} {
if _, err := core.CreateUser(user); err != nil {
t.Fatalf("create user %s: %v", user.ID, err)
}
}
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-owner", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owner Server",
OwnerUserID: "user-owner", State: domain.ServerInstanceStateReady, ConfigVersion: 1,
}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := core.CreateJob(domain.Job{
ID: "job-owner", ServerInstanceID: "server-owner", RunEndpointID: "run-local",
Capability: "process.start", IdempotencyKey: "job-owner",
}); err != nil {
t.Fatalf("create job: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", ""), http.StatusUnauthorized, errorCodeUnauthorized)
ownerAuth, err := core.LoginUser(domain.UserLogin{Account: "owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login owner: %v", err)
}
otherAuth, err := core.LoginUser(domain.UserLogin{Account: "other@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login other: %v", err)
}
ownerSession := ownerAuth.SessionID
otherSession := otherAuth.SessionID
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-owner", ownerSession)
if jobs.Count != 1 || jobs.Items[0].ID != "job-owner" {
t.Fatalf("owner did not receive own jobs: %+v", jobs)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", "", otherSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/jobs/job-owner/cancel", dto.RunJobCancelRequestBody{Reason: "cross-owner"}, otherSession), http.StatusForbidden, errorCodeForbidden)
encodedJobs, err := json.Marshal(jobs)
if err != nil {
t.Fatalf("encode safe job projection: %v", err)
}
for _, forbidden := range []string{"leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket"} {
if strings.Contains(string(encodedJobs), forbidden) {
t.Fatalf("job projection exposed forbidden field %q: %s", forbidden, encodedJobs)
}
}
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/game-plugins", validGamePluginRequest(), ownerSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/server.scum/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}), http.StatusUnauthorized, errorCodeUnauthorized)
}
func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
token := "run-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)
}
router := NewTestRouterWithCore(core)
request := dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local", SessionToken: token, Version: "0.1.1", Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal heartbeat: %v", err)
}
unsigned := performRequest(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
signed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertStatus(t, signed, http.StatusOK)
replayed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertErrorResponse(t, replayed, http.StatusUnauthorized, errorCodeUnauthorized)
if _, err := core.CreateJob(domain.Job{ID: "job-signed", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed"}); err != nil {
t.Fatalf("create signed job: %v", err)
}
claimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: token, Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal signed claim: %v", err)
}
unsignedClaim := performRequest(t, router, http.MethodPost, "/api/v1/run/jobs/claim", bytes.NewReader(claimBody))
assertErrorResponse(t, unsignedClaim, http.StatusUnauthorized, errorCodeUnauthorized)
signedClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", claimBody, token, "nonce-api-job-1", stamp)
assertStatus(t, signedClaim, http.StatusOK)
staleClaimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: "stale-session", Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal stale claim: %v", err)
}
staleClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", staleClaimBody, "stale-session", "nonce-api-job-2", stamp)
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
privateUpdateBodies := map[string]any{
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
}
nonce := 10
for path, request := range privateUpdateBodies {
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal private Run request for %s: %v", path, err)
}
unsigned := performRequest(t, router, http.MethodPost, path, bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
nonce++
signed := signedRunRequest(t, router, path, body, token, fmt.Sprintf("nonce-api-private-%d", nonce), stamp)
if signed.Code == http.StatusUnauthorized {
t.Fatalf("valid signature was rejected for %s: %s", path, signed.Body.String())
}
}
}
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)
bodyHash := sha256.Sum256(body)
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 := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Run-Endpoint", "run-local")
req.Header.Set("X-Run-Timestamp", timestamp)
req.Header.Set("X-Run-Nonce", nonce)
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
func TestSessionRotationRouteRevokesPreviousBearer(t *testing.T) {
router := newTestRouter()
current := createAdminSession(t, router)
rotated := postOKJSONWithAuth[dto.AuthSessionResponse](t, router, "/api/v1/auth/rotate", map[string]string{}, current)
if rotated.SessionID == "" || rotated.SessionID == current || rotated.ExpiresAt.IsZero() {
t.Fatalf("unexpected rotated session: %+v", rotated)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", current), http.StatusUnauthorized, errorCodeUnauthorized)
currentUser := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", rotated.SessionID)
if currentUser.ID != "user-admin" {
t.Fatalf("rotated session resolved unexpected user: %+v", currentUser)
}
}
@@ -0,0 +1,352 @@
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 only the controlled installation workspace while retaining audit and distribution history.
// @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))
}
+3 -2
View File
@@ -78,6 +78,7 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) {
SessionToken: hello.SessionToken, SessionToken: hello.SessionToken,
JobID: claim.Job.JobID, JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken, LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
}) })
assertStatus(t, pollRecorder, http.StatusOK) assertStatus(t, pollRecorder, http.StatusOK)
poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder) poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder)
@@ -104,11 +105,11 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) {
reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{ reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{
RunEndpointID: "run-local", RunEndpointID: "run-local",
SessionToken: hello.SessionToken, SessionToken: hello.SessionToken,
ActiveJobIDs: []string{"local-only"}, ActiveJobs: []dto.RunJobReconcileEntry{{JobID: "local-only", LeaseToken: "local-lease", Attempt: 1}},
}) })
assertStatus(t, reconcileRecorder, http.StatusOK) assertStatus(t, reconcileRecorder, http.StatusOK)
reconcile := decodeBody[dto.RunJobReconcileResponse](t, reconcileRecorder) reconcile := decodeBody[dto.RunJobReconcileResponse](t, reconcileRecorder)
if len(reconcile.ActiveJobs) != 0 || len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" { if len(reconcile.ConfirmedJobs) != 0 || len(reconcile.DiscardJobIDs) != 1 || reconcile.DiscardJobIDs[0] != "local-only" {
t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile) t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile)
} }
} }
+127
View File
@@ -0,0 +1,127 @@
package api
import (
"net/http"
"strconv"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
// runMetricBatchIngest godoc
// @Summary Ingest bounded Run metric samples
// @Description Persists a signed bounded metric batch for server instances owned by the Run endpoint.
// @Tags run-metrics
// @Accept json
// @Produce json
// @Param body body dto.MetricBatchIngestRequest true "Metric batch"
// @Success 200 {object} dto.MetricBatchIngestResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/metrics/batches [post]
func (h *coreHandlers) runMetricBatchIngest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.MetricBatchIngestRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.IngestMetricBatch(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.MetricBatchIngestFromDomain(result))
}
// metricHistory godoc
// @Summary Query persisted server metric samples
// @Description Returns an owner-authorized bounded metric history without machine credentials or fencing state.
// @Tags metrics
// @Produce json
// @Success 200 {object} dto.MetricSampleListResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/metrics/server-instances/history [get]
func (h *coreHandlers) metricHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.core.ListMetricSamplesForSession(bearerToken(r), domain.MetricSampleFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), After: parseQueryTime(r.URL.Query().Get("after")), Before: parseQueryTime(r.URL.Query().Get("before")), Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.MetricSampleListFromDomain(items))
}
// backups godoc
// @Summary Create or list durable backup records
// @Description Creates or returns owner-authorized backup metadata and recovery state.
// @Tags backups
// @Accept json
// @Produce json
// @Success 200 {object} dto.BackupListResponse
// @Success 201 {object} dto.BackupResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/backups [get]
// @Router /api/v1/backups [post]
func (h *coreHandlers) backups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
items, err := h.core.ListBackupsForSession(bearerToken(r), domain.BackupFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), State: domain.BackupState(r.URL.Query().Get("state"))})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.BackupListFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.BackupCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
record, err := h.core.CreateBackupForSession(bearerToken(r), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.BackupFromDomain(record))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
// backupDetail godoc
// @Summary Get one durable backup record
// @Tags backups
// @Produce json
// @Success 200 {object} dto.BackupResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/backups/{id} [get]
func (h *coreHandlers) backupDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
record, err := h.core.GetBackupForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.BackupFromDomain(record))
}
func parseQueryTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339Nano, value)
return parsed
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// remoteAdapters godoc
// @Summary List or request scoped remote adapters
// @Description Lists declared safe adapters or queues an owner-authorized fenced adapter job.
// @Tags remote-adapters
// @Accept json
// @Produce json
// @Success 200 {object} dto.RemoteAdapterDeclarationListResponse
// @Success 202 {object} dto.RemoteAdapterResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/remote-adapters [get]
// @Router /api/v1/server-instances/{id}/remote-adapters [post]
func (h *coreHandlers) remoteAdapters(w http.ResponseWriter, r *http.Request) {
serverInstanceID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
declarations, err := h.core.ListRemoteAdapterDeclarationsForSession(bearerToken(r), serverInstanceID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RemoteAdapterDeclarationsFromDomain(declarations))
case http.MethodPost:
request, err := decodeJSON[dto.RemoteAdapterRequestBody](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RequestRemoteAdapterForSession(bearerToken(r), request.ToDomain(serverInstanceID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.RemoteAdapterFromDomain(result))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
+223 -31
View File
@@ -13,16 +13,18 @@ import (
type coreHandlers struct { type coreHandlers struct {
core service.Core core service.Core
enforceAuthorization bool
} }
func newCoreHandlers(core service.Core) *coreHandlers { func newCoreHandlers(core service.Core, enforceAuthorization bool) *coreHandlers {
return &coreHandlers{core: core} return &coreHandlers{core: core, enforceAuthorization: enforceAuthorization}
} }
func (h *coreHandlers) register(mux *http.ServeMux) { func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/register", h.authRegister) mux.HandleFunc("/api/v1/auth/register", h.authRegister)
mux.HandleFunc("/api/v1/auth/login", h.authLogin) mux.HandleFunc("/api/v1/auth/login", h.authLogin)
mux.HandleFunc("/api/v1/auth/logout", h.authLogout) mux.HandleFunc("/api/v1/auth/logout", h.authLogout)
mux.HandleFunc("/api/v1/auth/rotate", h.authRotate)
mux.HandleFunc("/api/v1/users/current", h.currentUser) mux.HandleFunc("/api/v1/users/current", h.currentUser)
mux.HandleFunc("/api/v1/users/current/profile", h.currentUserProfile) mux.HandleFunc("/api/v1/users/current/profile", h.currentUserProfile)
mux.HandleFunc("/api/v1/users/current/theme", h.currentUserTheme) mux.HandleFunc("/api/v1/users/current/theme", h.currentUserTheme)
@@ -45,11 +47,18 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail) mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail)
mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics) mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics)
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics) mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
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)
mux.HandleFunc("/api/v1/backups/{id}", h.backupDetail)
mux.HandleFunc("/api/v1/server-instances", h.serverInstances) mux.HandleFunc("/api/v1/server-instances", h.serverInstances)
mux.HandleFunc("/api/v1/server-instances/workflows/create", h.serverInstanceCreateWorkflow) 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}/start", h.serverInstanceStart)
mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop) mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop)
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/actions", h.serverRuntimeActions)
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate) mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload) 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/key/reset", h.serverRunKeyReset)
@@ -57,8 +66,17 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate) 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/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/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}/dependencies/check", h.serverDependenciesCheck) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs) mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill) mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff) mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
@@ -69,19 +87,25 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail) mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail)
mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello) mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello)
mux.HandleFunc("/api/v1/run/control/heartbeat", h.runControlHeartbeat) mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
mux.HandleFunc("/api/v1/run/jobs/claim", h.runJobClaim) mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim))
mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck) mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck))
mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress) mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress))
mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult) mux.HandleFunc("/api/v1/run/jobs/result", h.requireRunSignature(h.runJobResult))
mux.HandleFunc("/api/v1/run/jobs/build-input", h.runJobBuildInput) mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll) mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile) mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest) mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
mux.HandleFunc("/api/v1/run/artifacts/open", h.runArtifactOpen) mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.runArtifactChunkUpload) mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
mux.HandleFunc("/api/v1/run/artifacts/status", h.runArtifactStatus) mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
mux.HandleFunc("/api/v1/run/artifacts/complete", h.runArtifactComplete) 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))
mux.HandleFunc("/api/v1/run/artifacts/open", h.requireRunSignature(h.runArtifactOpen))
mux.HandleFunc("/api/v1/run/artifacts/chunks", h.requireRunSignature(h.runArtifactChunkUpload))
mux.HandleFunc("/api/v1/run/artifacts/status", h.requireRunSignature(h.runArtifactStatus))
mux.HandleFunc("/api/v1/run/artifacts/complete", h.requireRunSignature(h.runArtifactComplete))
mux.HandleFunc("/api/v1/run/endpoints", h.runEndpoints) mux.HandleFunc("/api/v1/run/endpoints", h.runEndpoints)
mux.HandleFunc("/api/v1/run/endpoints/{id}", h.runEndpointDetail) mux.HandleFunc("/api/v1/run/endpoints/{id}", h.runEndpointDetail)
mux.HandleFunc("/api/v1/jobs", h.jobs) mux.HandleFunc("/api/v1/jobs", h.jobs)
@@ -97,6 +121,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail) mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail)
mux.HandleFunc("/api/v1/audit-events", h.auditEvents) mux.HandleFunc("/api/v1/audit-events", h.auditEvents)
mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail) mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail)
mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister)
mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat)
} }
// authRegister godoc // authRegister godoc
@@ -126,7 +152,7 @@ func (h *coreHandlers) authRegister(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err) writeServiceError(w, err)
return return
} }
writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) h.writeAuthSession(w, r, session)
} }
// authLogin godoc // authLogin godoc
@@ -157,7 +183,7 @@ func (h *coreHandlers) authLogin(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err) writeServiceError(w, err)
return return
} }
writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) h.writeAuthSession(w, r, session)
} }
// authLogout godoc // authLogout godoc
@@ -178,9 +204,32 @@ func (h *coreHandlers) authLogout(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err) writeServiceError(w, err)
return return
} }
h.clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// authRotate godoc
// @Summary Rotate the active platform session
// @Description Revokes the current bearer token and returns a new bounded session token.
// @Tags auth
// @Produce json
// @Success 200 {object} dto.AuthSessionResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/auth/rotate [post]
func (h *coreHandlers) authRotate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
session, err := h.core.RotateUserSession(bearerToken(r))
if err != nil {
writeServiceError(w, err)
return
}
h.writeAuthSession(w, r, session)
}
// currentUser godoc // currentUser godoc
// @Summary Get current platform user // @Summary Get current platform user
// @Description Returns the authenticated current user's bounded identity, roles, profile, and theme preference. // @Description Returns the authenticated current user's bounded identity, roles, profile, and theme preference.
@@ -274,10 +323,14 @@ func (h *coreHandlers) currentUserTheme(w http.ResponseWriter, r *http.Request)
func bearerToken(r *http.Request) string { func bearerToken(r *http.Request) string {
const prefix = "Bearer " const prefix = "Bearer "
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
if len(header) < len(prefix) || header[:len(prefix)] != prefix { if len(header) >= len(prefix) && header[:len(prefix)] == prefix {
return header[len(prefix):]
}
cookie, err := r.Cookie(platformSessionCookieName)
if err != nil {
return "" return ""
} }
return header[len(prefix):] return cookie.Value
} }
// pluginBridgeAuthorize godoc // pluginBridgeAuthorize godoc
@@ -302,7 +355,12 @@ func (h *coreHandlers) pluginBridgeAuthorize(w http.ResponseWriter, r *http.Requ
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
result, err := h.core.AuthorizePluginBridgeAction(request.ToDomain()) var result domain.PluginBridgeAuthorization
if h.enforceAuthorization {
result, err = h.core.AuthorizePluginBridgeActionForSession(bearerToken(r), request.ToDomain())
} else {
result, err = h.core.AuthorizePluginBridgeAction(request.ToDomain())
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -530,7 +588,11 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
provider, err := h.core.UpdateAIProvider(r.PathValue("id"), request.ToDomain(r.PathValue("id"), existing.Status)) update := request.ToDomain(r.PathValue("id"), existing.Status)
if strings.TrimSpace(update.APIKeyRef) == "" {
update.APIKeyRef = existing.APIKeyRef
}
provider, err := h.core.UpdateAIProvider(r.PathValue("id"), update)
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -1418,6 +1480,93 @@ func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, dto.DistributionBuildInputFromDomain(result)) writeJSON(w, http.StatusOK, dto.DistributionBuildInputFromDomain(result))
} }
// runJobDependencyInput returns declared and resolved dependency input only to the active fenced Run attempt.
func (h *coreHandlers) runJobDependencyInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.DependencyExecutionInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.GetDependencyExecutionInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DependencyExecutionInputFromDomain(result))
}
// runJobUpdateInput returns update metadata only to the active fenced Run attempt.
func (h *coreHandlers) runJobUpdateInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunUpdateInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.GetRunUpdateInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateInputFromDomain(result))
}
// runJobUpdateChunk serves one bounded update range only to the active fenced Run attempt.
func (h *coreHandlers) runJobUpdateChunk(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
}
result, err := h.core.ReadRunUpdateChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result))
}
// runJobUpdateHealth godoc
// @Summary Confirm a reconciled Run self-update outcome
// @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt.
// @Tags run-jobs
// @Accept json
// @Produce json
// @Param body body dto.RunUpdateHealthRequest true "Run update health report"
// @Success 200 {object} dto.RunUpdateHealthResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/update-health [post]
func (h *coreHandlers) runJobUpdateHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunUpdateHealthRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReportRunUpdateHealth(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateHealthFromDomain(result))
}
// runJobCancelPoll godoc // runJobCancelPoll godoc
// @Summary Poll run job cancellation // @Summary Poll run job cancellation
// @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs. // @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs.
@@ -1704,11 +1853,18 @@ func (h *coreHandlers) runEndpointDetail(w http.ResponseWriter, r *http.Request)
func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) jobs(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
jobs, err := h.core.ListJobs(domain.JobFilter{ filter := domain.JobFilter{
ServerInstanceID: r.URL.Query().Get("serverInstanceId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
RunEndpointID: r.URL.Query().Get("runEndpointId"), RunEndpointID: r.URL.Query().Get("runEndpointId"),
State: domain.JobState(r.URL.Query().Get("state")), State: domain.JobState(r.URL.Query().Get("state")),
}) }
var jobs []domain.Job
var err error
if h.enforceAuthorization {
jobs, err = h.core.ListJobsForSession(bearerToken(r), filter)
} else {
jobs, err = h.core.ListJobs(filter)
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -1755,7 +1911,12 @@ func (h *coreHandlers) jobCancel(w http.ResponseWriter, r *http.Request) {
return return
} }
request.JobID = r.PathValue("id") request.JobID = r.PathValue("id")
result, err := h.core.RequestRunJobCancel(request.ToDomain()) var result domain.RunJobCancelRequestResult
if h.enforceAuthorization {
result, err = h.core.RequestRunJobCancelForSession(bearerToken(r), request.ToDomain())
} else {
result, err = h.core.RequestRunJobCancel(request.ToDomain())
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -1778,7 +1939,13 @@ func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
return return
} }
job, err := h.core.GetJob(r.PathValue("id")) var job domain.Job
var err error
if h.enforceAuthorization {
job, err = h.core.GetJobForSession(bearerToken(r), r.PathValue("id"))
} else {
job, err = h.core.GetJob(r.PathValue("id"))
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -1802,11 +1969,18 @@ func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) {
func (h *coreHandlers) artifacts(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) artifacts(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
artifacts, err := h.core.ListArtifacts(domain.ArtifactFilter{ filter := domain.ArtifactFilter{
OwnerKind: domain.ArtifactOwnerKind(r.URL.Query().Get("ownerKind")), OwnerKind: domain.ArtifactOwnerKind(r.URL.Query().Get("ownerKind")),
OwnerID: r.URL.Query().Get("ownerId"), OwnerID: r.URL.Query().Get("ownerId"),
State: domain.ArtifactState(r.URL.Query().Get("state")), State: domain.ArtifactState(r.URL.Query().Get("state")),
}) }
var artifacts []domain.Artifact
var err error
if h.enforceAuthorization {
artifacts, err = h.core.ListArtifactsForSession(bearerToken(r), filter)
} else {
artifacts, err = h.core.ListArtifacts(filter)
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -1995,10 +2169,17 @@ func parseByteRange(header string) (int64, int, bool) {
func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ filter := domain.LogStreamFilter{
ServerInstanceID: r.URL.Query().Get("serverInstanceId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"),
StreamKey: r.URL.Query().Get("streamKey"), StreamKey: r.URL.Query().Get("streamKey"),
}) }
var streams []domain.LogStream
var err error
if h.enforceAuthorization {
streams, err = h.core.ListLogStreamsForSession(bearerToken(r), filter)
} else {
streams, err = h.core.ListLogStreams(filter)
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -2043,7 +2224,12 @@ func (h *coreHandlers) logStreamQuery(w http.ResponseWriter, r *http.Request) {
writeDecodeError(w, err) writeDecodeError(w, err)
return return
} }
result, err := h.core.QueryLogStream(request.ToDomain()) var result domain.LogStreamCursorResult
if h.enforceAuthorization {
result, err = h.core.QueryLogStreamForSession(bearerToken(r), request.ToDomain())
} else {
result, err = h.core.QueryLogStream(request.ToDomain())
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
@@ -2066,7 +2252,13 @@ func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
return return
} }
stream, err := h.core.GetLogStream(r.PathValue("id")) var stream domain.LogStream
var err error
if h.enforceAuthorization {
stream, err = h.core.GetLogStreamForSession(bearerToken(r), r.PathValue("id"))
} else {
stream, err = h.core.GetLogStream(r.PathValue("id"))
}
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
return return
+145 -11
View File
@@ -36,8 +36,8 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
assertListCount(t, users.Count, 2) assertListCount(t, users.Count, 2)
providerResponse := createAIProviderFixture(t, router, adminSession) providerResponse := createAIProviderFixture(t, router, adminSession)
if providerResponse.APIKeyRef != "secret://providers/openai" { if !providerResponse.APIKeyConfigured {
t.Fatalf("expected AI provider key reference, got %+v", providerResponse) t.Fatalf("expected AI provider key presence, got %+v", providerResponse)
} }
getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession) getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession)
providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession) providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession)
@@ -222,6 +222,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
Name: "Config API Server", Name: "Config API Server",
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, ownerSession) }, ownerSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession) config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession)
proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1) proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1)
@@ -317,6 +318,19 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
} }
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession) 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) 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)
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession) 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) assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
@@ -324,7 +338,11 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" { if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" {
t.Fatalf("unexpected dependency check job: %+v", dependencyCheck) t.Fatalf("unexpected dependency check job: %+v", dependencyCheck)
} }
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", IdempotencyKey: "api-dependency-install"}, adminSession) dependencyCatalog := getJSONWithAuth[dto.DependencyCatalogResponse](t, router, "/api/v1/server-instances/"+serverID+"/dependencies", adminSession)
if len(dependencyCatalog.Plans) != 1 || dependencyCatalog.Plans[0].Digest == "" {
t.Fatalf("expected reviewable dependency plan, got %+v", dependencyCatalog)
}
dependencyInstallRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "java-install", PlanDigest: dependencyCatalog.Plans[0].Digest, IdempotencyKey: "api-dependency-install"}, adminSession)
assertStatus(t, dependencyInstallRecorder, http.StatusAccepted) assertStatus(t, dependencyInstallRecorder, http.StatusAccepted)
dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder) dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder)
if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" { if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" {
@@ -469,19 +487,36 @@ func TestDefaultRouterSeedsLocalPlatformAdmin(t *testing.T) {
StorageBackend: "file", StorageBackend: "file",
MetadataPath: filepath.Join(t.TempDir(), "metadata.json"), MetadataPath: filepath.Join(t.TempDir(), "metadata.json"),
LogDir: filepath.Join(t.TempDir(), "logs"), LogDir: filepath.Join(t.TempDir(), "logs"),
BootstrapAdminEmail: "operator.local@example.test",
BootstrapAdminPassword: "operator-local",
}) })
if err != nil { if err != nil {
t.Fatalf("create default router: %v", err) t.Fatalf("create default router: %v", err)
} }
login := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{ loginRecorder := performJSON(t, router, http.MethodPost, "/api/v1/auth/login", dto.LoginRequest{
Account: "operator.local@example.test", Account: "operator.local@example.test",
Password: "operator-local", Password: "operator-local",
}) })
if login.SessionID == "" || login.Status != "authenticated" || login.User.ID != "user-admin" { assertStatus(t, loginRecorder, http.StatusOK)
login := decodeBody[dto.AuthSessionResponse](t, loginRecorder)
if login.SessionID != "" || login.Status != "authenticated" || login.User.ID != "user-admin" {
t.Fatalf("unexpected default operator login response: %+v", login) t.Fatalf("unexpected default operator login response: %+v", login)
} }
var sessionCookie *http.Cookie
for _, cookie := range loginRecorder.Result().Cookies() {
if cookie.Name == platformSessionCookieName {
sessionCookie = cookie
break
}
}
if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("expected strict HttpOnly session cookie, got %+v", sessionCookie)
}
current := requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", login.SessionID) currentRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users/current", nil)
currentRequest.AddCookie(sessionCookie)
current := httptest.NewRecorder()
router.ServeHTTP(current, currentRequest)
assertStatus(t, current, http.StatusOK) assertStatus(t, current, http.StatusOK)
currentUser := decodeBody[dto.CurrentUserResponse](t, current) currentUser := decodeBody[dto.CurrentUserResponse](t, current)
if currentUser.ID != "user-admin" || len(currentUser.Roles) == 0 || currentUser.Roles[0] != "platform-admin" { if currentUser.ID != "user-admin" || len(currentUser.Roles) == 0 || currentUser.Roles[0] != "platform-admin" {
@@ -601,6 +636,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
RunEndpointID: "run-local", RunEndpointID: "run-local",
Name: "SCUM Create", Name: "SCUM Create",
IdempotencyKey: "idem-create", IdempotencyKey: "idem-create",
ProfileKey: "local",
}, adminSession) }, adminSession)
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall { if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
t.Fatalf("expected create workflow response, got %+v", created) t.Fatalf("expected create workflow response, got %+v", created)
@@ -613,6 +649,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
Name: "SCUM Ready", Name: "SCUM Ready",
State: domain.ServerInstanceStateReady, State: domain.ServerInstanceStateReady,
}, adminSession) }, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-ready/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{ started := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-ready/start", dto.ServerLifecycleCommandRequest{
ExpectedConfigVersion: ready.ConfigVersion, ExpectedConfigVersion: ready.ConfigVersion,
IdempotencyKey: "idem-start", IdempotencyKey: "idem-start",
@@ -628,6 +665,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) {
Name: "SCUM Running", Name: "SCUM Running",
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, adminSession) }, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/server-running/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{ stopped := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/server-running/stop", dto.ServerLifecycleCommandRequest{
ExpectedConfigVersion: running.ConfigVersion, ExpectedConfigVersion: running.ConfigVersion,
IdempotencyKey: "idem-stop", IdempotencyKey: "idem-stop",
@@ -795,8 +833,8 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
if _, exists := body["rawApiKey"]; exists { if _, exists := body["rawApiKey"]; exists {
t.Fatalf("AI provider response must not expose rawApiKey: %+v", body) t.Fatalf("AI provider response must not expose rawApiKey: %+v", body)
} }
if body["apiKeyRef"] != "secret://providers/openai" { if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
t.Fatalf("expected apiKeyRef only, got %+v", body) t.Fatalf("expected API key presence only, got %+v", body)
} }
} }
@@ -809,7 +847,7 @@ func TestAIProviderManagementAPI(t *testing.T) {
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession) updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
assertStatus(t, updatedRecorder, http.StatusOK) assertStatus(t, updatedRecorder, http.StatusOK)
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder) updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive { if updated.Name != "OpenAI Relay" || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
t.Fatalf("unexpected updated provider: %+v", updated) t.Fatalf("unexpected updated provider: %+v", updated)
} }
@@ -1133,6 +1171,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
Name: "Bridge API Server", Name: "Bridge API Server",
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, ownerSession) }, ownerSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ lifecycleInstance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-bridge-lifecycle-api", ID: "server-bridge-lifecycle-api",
PluginID: "game.example", PluginID: "game.example",
@@ -1140,6 +1179,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
Name: "Bridge Lifecycle API Server", Name: "Bridge Lifecycle API Server",
State: domain.ServerInstanceStateReady, State: domain.ServerInstanceStateReady,
}, ownerSession) }, ownerSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+lifecycleInstance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ stream := postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-bridge-api", ID: "log-bridge-api",
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -1276,6 +1316,82 @@ func TestGamePluginManifestRegistryAPIRejectsUnsafeManifest(t *testing.T) {
} }
} }
func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command")
registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{
Discovery: []dto.RuntimeDiscoveryProbeBody{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}},
LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}},
TransportProfiles: []dto.RuntimeTransportProfileBody{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}},
}
registered := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 || registered.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
t.Fatalf("runtime profiles were not projected: %+v", registered.RuntimeProfiles)
}
endpoint := validRunEndpointRequest()
endpoint.Capabilities = append(endpoint.Capabilities, "remote.run.rcon.command")
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "runtime-binding-api", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Binding API", State: domain.ServerInstanceStateReady}, adminSession)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "runtime-binding-other", DisplayName: "Runtime Binding Other", Email: "runtime-binding-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "runtime-binding-other@example.test", Password: "secret-password"}).SessionID
assertErrorResponse(t, performRequest(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", nil), http.StatusUnauthorized, errorCodeUnauthorized)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/runtime-binding", "", otherSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local"}, otherSession), http.StatusForbidden, errorCodeForbidden)
unconfigured := getJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", adminSession)
if unconfigured.Configured || unconfigured.Reason != "runtime profile is not configured" {
t.Fatalf("unexpected unconfigured projection: %+v", unconfigured)
}
missingStart := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/start", dto.ServerLifecycleCommandRequest{ExpectedConfigVersion: server.ConfigVersion, IdempotencyKey: "api-start-missing-binding"}, adminSession)
assertErrorResponse(t, missingStart, http.StatusBadRequest, errorCodeValidation)
incompleteRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
assertStatus(t, incompleteRecorder, http.StatusOK)
incompleteBody := incompleteRecorder.Body.String()
incomplete := decodeBody[dto.RuntimeBindingResponse](t, incompleteRecorder)
if incomplete.Status != domain.RuntimeBindingStatusIncomplete || len(incomplete.MissingKeys) != 1 || incomplete.MissingKeys[0] != "rcon.password" {
t.Fatalf("unexpected incomplete projection: %+v", incomplete)
}
if strings.Contains(incompleteBody, "runtime.server-root") {
t.Fatalf("binding response exposed stored logical ref: %s", incompleteBody)
}
completeRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-binding-api/rcon"}}, adminSession)
assertStatus(t, completeRecorder, http.StatusOK)
completeBody := completeRecorder.Body.String()
complete := decodeBody[dto.RuntimeBindingResponse](t, completeRecorder)
secretFlag := false
for _, key := range complete.Keys {
if key.Key == "rcon.password" {
secretFlag = key.Configured && key.Secret
}
}
if complete.Status != domain.RuntimeBindingStatusComplete || !secretFlag {
t.Fatalf("unexpected complete projection: %+v", complete)
}
for _, forbidden := range []string{"secret://runtime-binding-api/rcon", "runtime.server-root", "/srv/game", "unix://", "tcp://", "password="} {
if strings.Contains(completeBody, forbidden) {
t.Fatalf("runtime binding response exposed %q: %s", forbidden, completeBody)
}
}
unsafe := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"server-root": "/srv/game"}}, adminSession)
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
undeclared := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"host.socket": "runtime.socket"}}, adminSession)
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
if created.Job.TargetKey != "local" {
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
}
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
assertErrorResponse(t, incompleteCreate, http.StatusBadRequest, errorCodeValidation)
missingServer := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/runtime-create-incomplete", "", adminSession)
assertErrorResponse(t, missingServer, http.StatusNotFound, errorCodeNotFound)
}
func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) { func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
router := newTestRouter() router := newTestRouter()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest()) recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
@@ -1297,11 +1413,11 @@ func newTestRouter() http.Handler {
if err := core.SeedLocalPlatformAdmin(); err != nil { if err := core.SeedLocalPlatformAdmin(); err != nil {
panic(err) panic(err)
} }
return NewRouterWithCore(core) return NewTestRouterWithCore(core)
} }
func apiRouterWithoutSeededAdmin() http.Handler { func apiRouterWithoutSeededAdmin() http.Handler {
return NewRouterWithCore(service.NewCoreService(repo.NewMemoryStore())) return NewTestRouterWithCore(service.NewCoreService(repo.NewMemoryStore()))
} }
func postJSON[T any](t *testing.T, router http.Handler, path string, body any) T { func postJSON[T any](t *testing.T, router http.Handler, path string, body any) T {
@@ -1501,6 +1617,11 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill, domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
} }
pluginRequest.DeclaredPermissions = []string{ pluginRequest.DeclaredPermissions = []string{
"server.read", "server.read",
@@ -1516,16 +1637,26 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
string(domain.PluginBridgeActionDependenciesRequest), string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest), 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.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) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
endpoint := validRunEndpointRequest() endpoint := validRunEndpointRequest()
endpoint.ID = "run-runtime" endpoint.ID = "run-runtime"
endpoint.Platform = "linux"
endpoint.Architecture = "amd64"
endpoint.Capabilities = append(endpoint.Capabilities, endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild, domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill, domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
) )
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
@@ -1536,6 +1667,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
Name: "Runtime API Server", Name: "Runtime API Server",
State: domain.ServerInstanceStateReady, State: domain.ServerInstanceStateReady,
}, adminSession) }, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-runtime-api", ID: "log-runtime-api",
ServerInstanceID: server.ID, ServerInstanceID: server.ID,
@@ -1594,6 +1726,7 @@ func validGamePluginRequest() dto.GamePluginCreateRequest {
Start: "actions/start.json", Start: "actions/start.json",
Stop: "actions/stop.json", Stop: "actions/stop.json",
}, },
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
} }
} }
@@ -1639,6 +1772,7 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
}, },
}, },
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
}, },
} }
} }
+40 -4
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"browser.local/platform/config" "browser.local/platform/config"
@@ -27,18 +28,53 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
core := service.NewCoreServiceWithLogStore(store, logStore) artifactDir := strings.TrimSpace(cfg.ArtifactDir)
if err := core.SeedLocalPlatformAdmin(); err != nil { if artifactDir == "" {
if strings.TrimSpace(cfg.DataDir) != "" {
artifactDir = filepath.Join(cfg.DataDir, "artifacts")
} else {
artifactDir = filepath.Join(filepath.Dir(cfg.MetadataPath), "artifacts")
}
}
artifactStore, err := service.NewFileArtifactBodyStore(artifactDir)
if err != nil {
return nil, err return nil, err
} }
return NewRouterWithCore(core), nil core, err := service.NewCoreServiceWithDurableStores(store, logStore, artifactStore)
if err != nil {
return nil, err
}
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
return nil, err
}
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
return nil, err
}
}
return NewAuthorizedRouterWithCore(core), nil
} }
func NewRouterWithCore(core service.Core) http.Handler { func NewRouterWithCore(core service.Core) http.Handler {
handlers := newCoreHandlers(core) return newRouterWithCore(core, true)
}
func NewAuthorizedRouterWithCore(core service.Core) http.Handler {
return newRouterWithCore(core, true)
}
func NewTestRouterWithCore(core service.Core) http.Handler {
return newRouterWithCore(core, false)
}
func newRouterWithCore(core service.Core, enforceAuthorization bool) http.Handler {
handlers := newCoreHandlers(core, enforceAuthorization)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", HealthHandler) mux.HandleFunc("/healthz", HealthHandler)
handlers.register(mux) handlers.register(mux)
if enforceAuthorization {
return handlers.requireAuthorizedAPI(mux)
}
return mux return mux
} }
+37 -14
View File
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| 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 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` | | 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 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`, `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`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyJobRequest`, `LogBackfillRequest` | | 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`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` | | Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
@@ -43,13 +43,14 @@ All routes use JSON request and response bodies. Collection routes support `GET`
## Implemented Authentication And Current User Actions ## Implemented Authentication And Current User Actions
- `POST /api/v1/auth/register`: accept `RegisterRequest`; the first registered account becomes an active platform administrator with an authenticated session, while later registrations create pending low-privilege users and return `AuthSessionResponse` with `status=pending` and no session token. - `POST /api/v1/auth/register`: accept `RegisterRequest`; the first registered account becomes an active platform administrator with an authenticated session, while later registrations create pending low-privilege users and return `AuthSessionResponse` with `status=pending` and no session token.
- `POST /api/v1/auth/login`: accept `LoginRequest`, authenticate an active user by ID or email, and return `AuthSessionResponse` with a bearer session token. - `POST /api/v1/auth/login`: accept `LoginRequest` and authenticate an active user by ID or email. Strict production routes set an HttpOnly SameSite cookie and omit the raw token from JSON; explicit CLI callers may request a bearer response with `X-Auth-Token-Response: bearer`.
- `POST /api/v1/auth/logout`: invalidate the active bearer session token and return `204`. - `POST /api/v1/auth/logout`: invalidate the active bearer session token and return `204`.
- `POST /api/v1/auth/rotate`: durably revoke the current bearer generation and return a new bounded session token and expiry.
- `GET /api/v1/users/current`: return `CurrentUserResponse` for the bearer session. - `GET /api/v1/users/current`: return `CurrentUserResponse` for the bearer session.
- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`. - `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`.
- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`. - `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`.
Auth responses never expose password hashes or raw credentials. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. Tests and local fixtures may seed one explicit platform administrator account for manual login: `operator.local@example.test` / `operator-local`. Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/audit/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value.
## Implemented Role-Scoped Server Access ## Implemented Role-Scoped Server Access
@@ -89,6 +90,8 @@ Config write and file dispatch responses expose only logical target keys, scoped
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly. AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
## Implemented Game Plugin Registry Actions ## Implemented Game Plugin Registry Actions
- `POST /api/v1/game-plugins/register-manifest`: accept `GamePluginManifestRegistrationRequest`, validate a game management plugin manifest, and persist installed registry metadata using `GamePluginResponse`. - `POST /api/v1/game-plugins/register-manifest`: accept `GamePluginManifestRegistrationRequest`, validate a game management plugin manifest, and persist installed registry metadata using `GamePluginResponse`.
@@ -124,21 +127,31 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
## Implemented Runtime Distribution And Client Manager Actions ## Implemented Runtime Distribution And Client Manager 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. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons. - `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only. - `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, and redacted secret ref only.
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization. - `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
- `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/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`. - `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 audit 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/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/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`. - `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/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 queues `dependencies.install` only for typed plugin-declared plans. - `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.
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`. - `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results. - `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit 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 and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and run endpoint capability support for run-side jobs. Responses and audit 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.
`POST /api/v1/server-instances/workflows/create` requires `profileKey` and initial `bindings`. Platform validates completeness and persists the binding before dispatching the install job; the job `targetKey` identifies the selected declared profile. Existing servers without a binding remain readable, but lifecycle and runtime-dependent actions return a safe configuration-required reason.
## Private Run Dependency And Update Routes
The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes.
## Implemented Run Control Actions ## Implemented Run Control Actions
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token. - `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
@@ -149,15 +162,15 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
## Implemented Run Job Actions ## Implemented Run Job Actions
- `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active run session, lease one queued job for that endpoint, and return `RunJobClaimResponse`. - `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active Run session, sweep expired endpoint work, and durably claim one eligible queued/retrying job with a monotonic per-job attempt, hashed lease credential, ack deadline, and execution lease.
- `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest` and move an active leased job into running state. - `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest`, fence endpoint/session generation/attempt/lease, reject late acknowledgements, and move the current attempt into running state.
- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest` and update bounded progress metadata. - `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest`, reject stale sequences and expired/old attempts, persist bounded progress, and renew the current execution lease.
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal job result. - `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff.
- `POST /api/v1/run/jobs/cancel`: accept `RunJobCancelPollRequest` and return pending cancellation metadata for active leases. - `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt.
- `POST /api/v1/run/jobs/reconcile`: accept `RunJobReconcileRequest` and return platform-known active jobs plus unknown run-reported job IDs. - `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`: accept `RunJobCancelRequestBody` and record a platform cancellation request for run polling. - `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, progress, terminal state, message, error code, result reference, and timing hints. They do not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies. 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.
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. 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 Log Ingest Actions ## Implemented Log Ingest Actions
@@ -186,7 +199,16 @@ Artifact/file transfer is lower priority than control, job lifecycle metadata, a
- `POST /api/v1/artifacts/{id}/download`: returns `ArtifactDownloadReferenceResponse` with filename, content type, size, checksum, expiry, supported chunk size, and a platform-owned `downloadUrl`. - `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`: 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 reconstruct completed upload chunks from the in-memory platform transfer session; durable external 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. 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.
## Durable Observability And Scoped Remote Adapters
- `POST /api/v1/run/metrics/batches`: accepts a signed bounded metric batch for the Run endpoint's server instances and returns an acknowledgement count.
- `GET /api/v1/metrics/server-instances/history`: returns a bounded owner-scoped metric history by server instance and optional time/limit query.
- `GET|POST /api/v1/backups` and `GET /api/v1/backups/{id}`: expose or create safe backup metadata, checksum, artifact reference, retention, and recovery state; body bytes and storage paths remain private.
- `GET|POST /api/v1/server-instances/{id}/remote-adapters`: lists manifest-declared adapter capabilities or queues an owner-authorized fenced adapter job using logical target keys. Requests never carry arbitrary shell, socket, host, or credential data.
Metric, backup, log, and artifact records use the configured durable metadata/body stores. Control, job, log, artifact, metric, and remote adapter traffic remain independent channels; slow artifact or adapter retries do not share lightweight heartbeat or job result payloads.
## Error Contract ## Error Contract
@@ -207,9 +229,10 @@ These route groups remain documented future work beyond the currently implemente
- Authorization policy routes beyond role-scoped navigation and bearer session identity. - Authorization policy routes beyond role-scoped navigation and bearer session identity.
- Run control transport beyond hello and heartbeat, including heartbeat reconciliation policies. - Run control transport beyond hello and heartbeat, including heartbeat reconciliation policies.
- External metrics collectors, browser tail transport, external log body backends, and AI log analysis windows. - External metrics collectors, browser tail transport, external log body backends, and AI log analysis windows.
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies. - External artifact storage backends, presigned URLs, and production throttling policies. Run self-update range reads and local artifact upload are implemented, but production mirrors/signing are not.
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts. - Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
- Live AI provider connectivity tests and remote model discovery. - Live AI provider connectivity tests and remote model discovery.
- Production Run distribution signing/KMS, fleet rollout rings, client-manager lifecycle, plugin lifecycle, production scaling/alerts, and real AI-provider integration.
- Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions. - Server restart/delete routes beyond the currently implemented lifecycle, metadata update, and archive actions.
## Core Service Boundary ## Core Service Boundary
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"browser.local/platform/domain"
"browser.local/platform/service"
)
const (
runEndpointHeader = "X-Run-Endpoint"
runTimestampHeader = "X-Run-Timestamp"
runNonceHeader = "X-Run-Nonce"
runSignatureHeader = "X-Run-Signature"
)
type runRequestEnvelope struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
}
func (h *coreHandlers) requireRunSignature(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.Body == nil {
next(w, r)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
if err != nil {
writeDecodeError(w, err)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
var envelope runRequestEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
next(w, r)
return
}
headerEndpoint := strings.TrimSpace(r.Header.Get(runEndpointHeader))
if headerEndpoint != "" && headerEndpoint != envelope.RunEndpointID {
writeServiceError(w, service.ErrUnauthorized)
return
}
bodySum := sha256.Sum256(body)
err = h.core.AuthorizeRunRequestSignature(domain.RunRequestSignature{
RunEndpointID: envelope.RunEndpointID,
SessionToken: envelope.SessionToken,
Method: r.Method,
Path: r.URL.Path,
Timestamp: strings.TrimSpace(r.Header.Get(runTimestampHeader)),
Nonce: strings.TrimSpace(r.Header.Get(runNonceHeader)),
BodyHash: hex.EncodeToString(bodySum[:]),
Signature: strings.TrimSpace(r.Header.Get(runSignatureHeader)),
})
if err != nil {
writeServiceError(w, err)
return
}
next(w, r)
}
}
+32
View File
@@ -98,3 +98,35 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
} }
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result)) writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
} }
// serverInstanceProcessStatus godoc
// @Summary Query supervised server process status
// @Description Queues a typed process.status job for the selected server/profile.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Process status request"
// @Success 202 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/process/status [post]
func (h *coreHandlers) serverInstanceProcessStatus(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.QueryServerInstanceProcessForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
}
+74 -1
View File
@@ -7,6 +7,48 @@ import (
"browser.local/platform/dto" "browser.local/platform/dto"
) )
// serverRuntimeBinding godoc
// @Summary Review or update a server runtime binding
// @Description GET returns redacted logical readiness metadata. PUT changes the selected declared profile and logical refs for an authorized owner or platform administrator.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.RuntimeBindingUpdateRequest false "Runtime binding update"
// @Success 200 {object} dto.RuntimeBindingResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/runtime-binding [get]
// @Router /api/v1/server-instances/{id}/runtime-binding [put]
func (h *coreHandlers) serverRuntimeBinding(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
binding, err := h.core.GetServerRuntimeBindingForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RuntimeBindingFromDomain(binding))
case http.MethodPut:
request, err := decodeJSON[dto.RuntimeBindingUpdateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
binding, err := h.core.UpdateServerRuntimeBindingForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RuntimeBindingFromDomain(binding))
default:
writeMethodNotAllowed(w, "GET, PUT")
}
}
func (h *coreHandlers) serverRuntimeActions(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverRuntimeActions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
@@ -65,8 +107,17 @@ func (h *coreHandlers) serverRunKeyReset(w http.ResponseWriter, r *http.Request)
} }
func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
jobs, err := h.core.ListRunUpdateJobsForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateJobListFromDomain(jobs))
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, "GET, POST")
return return
} }
request, err := decodeJSON[dto.RunUpdateRequest](r) request, err := decodeJSON[dto.RunUpdateRequest](r)
@@ -82,6 +133,28 @@ func (h *coreHandlers) serverRunUpdate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, dto.RunUpdateJobFromDomain(job)) writeJSON(w, http.StatusAccepted, dto.RunUpdateJobFromDomain(job))
} }
// serverDependencies godoc
// @Summary List declared dependency probes, reviewable install plans, and safe status
// @Description Returns only target, digest, typed step, and bounded dependency evidence for an authorized server user.
// @Tags server-instances
// @Produce json
// @Success 200 {object} dto.DependencyCatalogResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/dependencies [get]
func (h *coreHandlers) serverDependencies(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
catalog, err := h.core.GetDependencyCatalogForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DependencyCatalogFromDomain(catalog))
}
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
const platformSessionCookieName = "platform_session"
func (h *coreHandlers) writeAuthSession(w http.ResponseWriter, r *http.Request, session domain.AuthSession) {
response := dto.AuthSessionFromDomain(session)
if h.enforceAuthorization && strings.TrimSpace(session.SessionID) != "" {
h.setSessionCookie(w, r, session.SessionID, session.ExpiresAt)
if r.Header.Get("X-Auth-Token-Response") != "bearer" {
response.SessionID = ""
}
}
writeJSON(w, http.StatusOK, response)
}
func (h *coreHandlers) setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: platformSessionCookieName,
Value: token,
Path: "/api/v1",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
}
func (h *coreHandlers) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: platformSessionCookieName,
Value: "",
Path: "/api/v1",
MaxAge: -1,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
}
+12
View File
@@ -18,7 +18,11 @@ type Config struct {
DataDir string DataDir string
MetadataPath string MetadataPath string
LogDir string LogDir string
ArtifactDir string
LogBodyBackend string LogBodyBackend string
BootstrapAdminEmail string
BootstrapAdminPassword string
SecretEnvelopeKey string
} }
func Load() Config { func Load() Config {
@@ -40,6 +44,10 @@ func Load() Config {
if logDir == "" { if logDir == "" {
logDir = filepath.Join(dataDir, "logs") logDir = filepath.Join(dataDir, "logs")
} }
artifactDir := strings.TrimSpace(os.Getenv("PLATFORM_ARTIFACT_DIR"))
if artifactDir == "" {
artifactDir = filepath.Join(dataDir, "artifacts")
}
storageBackend := strings.TrimSpace(os.Getenv("PLATFORM_STORAGE_BACKEND")) storageBackend := strings.TrimSpace(os.Getenv("PLATFORM_STORAGE_BACKEND"))
if storageBackend == "" { if storageBackend == "" {
storageBackend = defaultStorageBackend storageBackend = defaultStorageBackend
@@ -53,7 +61,11 @@ func Load() Config {
DataDir: dataDir, DataDir: dataDir,
MetadataPath: metadataPath, MetadataPath: metadataPath,
LogDir: logDir, LogDir: logDir,
ArtifactDir: artifactDir,
LogBodyBackend: logBodyBackend, LogBodyBackend: logBodyBackend,
BootstrapAdminEmail: strings.TrimSpace(os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL")),
BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"),
SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"),
} }
} }
+10 -1
View File
@@ -15,6 +15,9 @@ func TestLoadUsesDefaultAddress(t *testing.T) {
t.Setenv("PLATFORM_METADATA_PATH", "") t.Setenv("PLATFORM_METADATA_PATH", "")
t.Setenv("PLATFORM_LOG_DIR", "") t.Setenv("PLATFORM_LOG_DIR", "")
t.Setenv("PLATFORM_LOG_BODY_BACKEND", "") t.Setenv("PLATFORM_LOG_BODY_BACKEND", "")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "")
t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "")
cfg := Load() cfg := Load()
if cfg.Addr != defaultAddr { if cfg.Addr != defaultAddr {
@@ -36,12 +39,15 @@ func TestLoadUsesConfiguredAddress(t *testing.T) {
t.Setenv("PLATFORM_METADATA_PATH", "/tmp/platform-metadata.json") t.Setenv("PLATFORM_METADATA_PATH", "/tmp/platform-metadata.json")
t.Setenv("PLATFORM_LOG_DIR", "/tmp/platform-logs") t.Setenv("PLATFORM_LOG_DIR", "/tmp/platform-logs")
t.Setenv("PLATFORM_LOG_BODY_BACKEND", "file") t.Setenv("PLATFORM_LOG_BODY_BACKEND", "file")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL", "admin@example.test")
t.Setenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", "configured-secret")
t.Setenv("PLATFORM_SECRET_ENVELOPE_KEY", "configured-envelope-key-at-least-32-bytes")
cfg := Load() cfg := Load()
if cfg.Addr != ":18080" { if cfg.Addr != ":18080" {
t.Fatalf("expected configured addr, got %q", cfg.Addr) t.Fatalf("expected configured addr, got %q", cfg.Addr)
} }
if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" { if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" || cfg.BootstrapAdminEmail != "admin@example.test" || cfg.BootstrapAdminPassword != "configured-secret" || cfg.SecretEnvelopeKey != "configured-envelope-key-at-least-32-bytes" {
t.Fatalf("unexpected configured storage: %+v", cfg) t.Fatalf("unexpected configured storage: %+v", cfg)
} }
} }
@@ -97,6 +103,9 @@ func clearPlatformEnv(t *testing.T) {
"PLATFORM_METADATA_PATH", "PLATFORM_METADATA_PATH",
"PLATFORM_LOG_DIR", "PLATFORM_LOG_DIR",
"PLATFORM_LOG_BODY_BACKEND", "PLATFORM_LOG_BODY_BACKEND",
"PLATFORM_BOOTSTRAP_ADMIN_EMAIL",
"PLATFORM_BOOTSTRAP_ADMIN_PASSWORD",
"PLATFORM_SECRET_ENVELOPE_KEY",
} { } {
t.Setenv(key, "") t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil { if err := os.Unsetenv(key); err != nil {
+350
View File
@@ -0,0 +1,350 @@
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"
)
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
}
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)
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
}
+24 -1
View File
@@ -19,6 +19,9 @@ type RunControlHello struct {
Version string Version string
Status RunEndpointStatus Status RunEndpointStatus
Platform string Platform string
Architecture string
UpdateJobID string
UpdateOutcome string
CapabilityReport RunCapabilityReport CapabilityReport RunCapabilityReport
Capacity RunCapacity Capacity RunCapacity
} }
@@ -29,6 +32,7 @@ type RunControlHelloResult struct {
SessionToken string SessionToken string
ServerTime time.Time ServerTime time.Time
HeartbeatIntervalSeconds int HeartbeatIntervalSeconds int
SessionExpiresAt time.Time
FeatureFlags []string FeatureFlags []string
} }
@@ -51,11 +55,29 @@ type RunControlHeartbeatResult struct {
type RunControlSession struct { type RunControlSession struct {
RunEndpointID string RunEndpointID string
SessionToken string SessionToken string `json:"-"`
SessionTokenHash string
Status AuthSessionStatus
Generation int
CapabilityFingerprint string CapabilityFingerprint string
HeartbeatIntervalSeconds int HeartbeatIntervalSeconds int
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
ExpiresAt time.Time
RevokedAt time.Time
RequireSignedRequests bool
UsedNonces []string
}
type RunRequestSignature struct {
RunEndpointID string
SessionToken string
Method string
Path string
Timestamp string
Nonce string
BodyHash string
Signature string
} }
func CopyRunCapabilityReport(report RunCapabilityReport) RunCapabilityReport { func CopyRunCapabilityReport(report RunCapabilityReport) RunCapabilityReport {
@@ -82,5 +104,6 @@ func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlH
} }
func CopyRunControlSession(session RunControlSession) RunControlSession { func CopyRunControlSession(session RunControlSession) RunControlSession {
session.UsedNonces = CopyStringSlice(session.UsedNonces)
return session return session
} }
+134 -21
View File
@@ -18,8 +18,14 @@ type RunJobAssignment struct {
State JobState State JobState
Progress RunJobProgressReport Progress RunJobProgressReport
ResultRef string ResultRef string
ExecutionInput JobExecutionInput
LeaseToken string LeaseToken string
Attempt int Attempt int
MaxAttempts int
AckDeadlineAt time.Time
LeaseExpiresAt time.Time
NextAttemptAt time.Time
ProgressSequence uint64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -82,6 +88,8 @@ type RunJobResult struct {
ResultRef string ResultRef string
Message string Message string
ErrorCode string ErrorCode string
Retryable bool
ExecutionResult JobExecutionResult
} }
type RunJobResultResult struct { type RunJobResultResult struct {
@@ -107,6 +115,7 @@ type DistributionBuildInput struct {
ProfileKey string ProfileKey string
TargetOS string TargetOS string
TargetArch string TargetArch string
TargetRelease string
PackageFormat string PackageFormat string
RepositoryURL string RepositoryURL string
SourceRevision string SourceRevision string
@@ -117,6 +126,103 @@ type DistributionBuildInput struct {
AuthKey string AuthKey string
} }
type DependencyExecutionInputRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
}
type DependencyExecutionInput struct {
JobID string
ServerInstanceID string
RunEndpointID string
PluginID string
PluginVersion string
ProfileKey string
TargetOS string
TargetArch string
PlanDigest string
Probe RuntimeDependencyProbe
Plan RuntimeInstallPlan
Bindings map[string]string
}
type RunUpdateInputRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
}
type RunUpdateInput struct {
JobID string
ServerInstanceID string
RunEndpointID string
ArtifactID string
Checksum string
SizeBytes int64
TargetOS string
TargetArch string
PackageFormat string
ExecutableName string
TargetRelease string
ChunkSizeBytes int
}
type RunUpdateChunkRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Offset int64
Length int
}
type RunUpdateChunk struct {
JobID string
ArtifactID string
Offset int64
TotalBytes int64
Checksum string
Payload []byte
Complete bool
}
type RunUpdateHealthReport struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Outcome string
Version string
}
type RunUpdateHealthResult struct {
Accepted bool
JobID string
Phase RunUpdatePhase
ServerTime time.Time
}
type DependencyExecutionEvidence struct {
ProbeKey string `json:"probeKey"`
PlanKey string `json:"planKey,omitempty"`
PlanDigest string `json:"planDigest"`
State string `json:"state"`
Evidence string `json:"evidence,omitempty"`
CompletedSteps int `json:"completedSteps,omitempty"`
}
type RunUpdateExecutionEvidence struct {
TargetRelease string `json:"targetRelease"`
Phase string `json:"phase"`
}
type RunJobCancelRequest struct { type RunJobCancelRequest struct {
JobID string JobID string
Reason string Reason string
@@ -127,6 +233,8 @@ type RunJobCancelRequestResult struct {
JobID string JobID string
Reason string Reason string
RequestedAt time.Time RequestedAt time.Time
CompletedAt time.Time
State JobState
} }
type RunJobCancelPoll struct { type RunJobCancelPoll struct {
@@ -134,6 +242,7 @@ type RunJobCancelPoll struct {
SessionToken string SessionToken string
JobID string JobID string
LeaseToken string LeaseToken string
Attempt int
} }
type RunJobCancelPollResult struct { type RunJobCancelPollResult struct {
@@ -146,33 +255,26 @@ type RunJobCancelPollResult struct {
ServerTime time.Time ServerTime time.Time
} }
type RunJobReconcileEntry struct {
JobID string
LeaseToken string
Attempt int
}
type RunJobReconcile struct { type RunJobReconcile struct {
RunEndpointID string RunEndpointID string
SessionToken string SessionToken string
ActiveJobIDs []string ActiveJobs []RunJobReconcileEntry
} }
type RunJobReconcileResult struct { type RunJobReconcileResult struct {
Accepted bool Accepted bool
RunEndpointID string RunEndpointID string
ActiveJobs []RunJobAssignment ConfirmedJobs []RunJobAssignment
UnknownJobIDs []string DiscardJobIDs []string
ServerTime time.Time ServerTime time.Time
} }
type RunJobLease struct {
JobID string
RunEndpointID string
SessionToken string
LeaseToken string
Attempt int
CancelReason string
CancelRequestedAt time.Time
TerminalFingerprint string
CreatedAt time.Time
UpdatedAt time.Time
}
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment { func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
return assignment return assignment
} }
@@ -196,13 +298,15 @@ func CopyRunJobClaimResult(result RunJobClaimResult) RunJobClaimResult {
} }
func CopyRunJobReconcile(reconcile RunJobReconcile) RunJobReconcile { func CopyRunJobReconcile(reconcile RunJobReconcile) RunJobReconcile {
reconcile.ActiveJobIDs = CopyStringSlice(reconcile.ActiveJobIDs) if reconcile.ActiveJobs != nil {
reconcile.ActiveJobs = append([]RunJobReconcileEntry(nil), reconcile.ActiveJobs...)
}
return reconcile return reconcile
} }
func CopyRunJobReconcileResult(result RunJobReconcileResult) RunJobReconcileResult { func CopyRunJobReconcileResult(result RunJobReconcileResult) RunJobReconcileResult {
result.ActiveJobs = CopyRunJobAssignments(result.ActiveJobs) result.ConfirmedJobs = CopyRunJobAssignments(result.ConfirmedJobs)
result.UnknownJobIDs = CopyStringSlice(result.UnknownJobIDs) result.DiscardJobIDs = CopyStringSlice(result.DiscardJobIDs)
return result return result
} }
@@ -215,6 +319,15 @@ func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment {
return out return out
} }
func CopyRunJobLease(lease RunJobLease) RunJobLease { func CopyDependencyExecutionInput(input DependencyExecutionInput) DependencyExecutionInput {
return lease input.Probe.Platforms = CopyStringSlice(input.Probe.Platforms)
input.Plan.Platforms = CopyStringSlice(input.Plan.Platforms)
input.Plan.Steps = append([]RuntimeInstallStep(nil), input.Plan.Steps...)
input.Bindings = CopyStringMap(input.Bindings)
return input
}
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
chunk.Payload = append([]byte(nil), chunk.Payload...)
return chunk
} }
+185
View File
@@ -0,0 +1,185 @@
package domain
import "time"
// MetricSample is a bounded, platform-owned observation for one server instance.
type MetricSample struct {
ID string
ServerInstanceID string
RunEndpointID string
Online bool
PlayerCount *int
MaxPlayers *int
TPS *float64
LatencyMS *float64
CPUPercent *float64
MemoryPercent *float64
DiskPercent *float64
Source string
CollectedAt time.Time
}
type MetricSampleFilter struct {
ServerInstanceID string
After time.Time
Before time.Time
Limit int
}
type MetricBatchIngest struct {
RunEndpointID string
SessionToken string
Samples []MetricSample
}
type MetricBatchIngestResult struct {
Accepted bool
AcceptedCount int
LatestAt time.Time
ServerTime time.Time
}
type BackupState string
const (
BackupStatePending BackupState = "pending"
BackupStateAvailable BackupState = "available"
BackupStateFailed BackupState = "failed"
BackupStateExpired BackupState = "expired"
)
type BackupRecord struct {
ID string
ServerInstanceID string
ArtifactID string
Checksum string
SizeBytes int64
State BackupState
RecoveryStatus string
RetentionUntil time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type BackupFilter struct {
ServerInstanceID string
State BackupState
}
type RemoteAdapterKind string
const (
RemoteAdapterFTP RemoteAdapterKind = "ftp"
RemoteAdapterRsync RemoteAdapterKind = "rsync"
RemoteAdapterRunFile RemoteAdapterKind = "run-file"
RemoteAdapterRunProcess RemoteAdapterKind = "run-process"
RemoteAdapterDatabase RemoteAdapterKind = "database"
RemoteAdapterRCON RemoteAdapterKind = "rcon"
)
type RemoteAdapterDeclaration struct {
Key string
Kind RemoteAdapterKind
TargetKeys []string
Capabilities []string
TimeoutSeconds int
MaxAttempts int
}
type RemoteAdapterRequest struct {
ServerInstanceID string
DeclarationKey string
TargetKey string
Capability string
TimeoutSeconds int
MaxAttempts int
IdempotencyKey string
}
type RemoteAdapterResult struct {
RequestID string
ServerInstanceID string
DeclarationKey string
TargetKey string
Kind RemoteAdapterKind
Status string
Retryable bool
Message string
ResultRef string
AuditEventID string
CompletedAt time.Time
}
func CopyMetricSample(sample MetricSample) MetricSample {
sample.PlayerCount = copyIntPtr(sample.PlayerCount)
sample.MaxPlayers = copyIntPtr(sample.MaxPlayers)
sample.TPS = copyFloatPtr(sample.TPS)
sample.LatencyMS = copyFloatPtr(sample.LatencyMS)
sample.CPUPercent = copyFloatPtr(sample.CPUPercent)
sample.MemoryPercent = copyFloatPtr(sample.MemoryPercent)
sample.DiskPercent = copyFloatPtr(sample.DiskPercent)
return sample
}
func copyIntPtr(value *int) *int {
if value == nil {
return nil
}
copy := *value
return &copy
}
func copyFloatPtr(value *float64) *float64 {
if value == nil {
return nil
}
copy := *value
return &copy
}
func CopyMetricSamples(samples []MetricSample) []MetricSample {
if samples == nil {
return nil
}
out := make([]MetricSample, len(samples))
for i, sample := range samples {
out[i] = CopyMetricSample(sample)
}
return out
}
func CopyMetricBatchIngest(batch MetricBatchIngest) MetricBatchIngest {
batch.Samples = CopyMetricSamples(batch.Samples)
return batch
}
func CopyBackupRecord(record BackupRecord) BackupRecord { return record }
func CopyBackupRecords(records []BackupRecord) []BackupRecord {
if records == nil {
return nil
}
out := make([]BackupRecord, len(records))
copy(out, records)
return out
}
func CopyRemoteAdapterDeclaration(declaration RemoteAdapterDeclaration) RemoteAdapterDeclaration {
declaration.TargetKeys = CopyStringSlice(declaration.TargetKeys)
declaration.Capabilities = CopyStringSlice(declaration.Capabilities)
return declaration
}
func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []RemoteAdapterDeclaration {
if declarations == nil {
return nil
}
out := make([]RemoteAdapterDeclaration, len(declarations))
for i, declaration := range declarations {
out[i] = CopyRemoteAdapterDeclaration(declaration)
}
return out
}
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { return request }
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
+354
View File
@@ -81,6 +81,7 @@ const (
JobStateQueued JobState = "queued" JobStateQueued JobState = "queued"
JobStateAccepted JobState = "accepted" JobStateAccepted JobState = "accepted"
JobStateRunning JobState = "running" JobStateRunning JobState = "running"
JobStateRetrying JobState = "retrying"
JobStateSucceeded JobState = "succeeded" JobStateSucceeded JobState = "succeeded"
JobStateFailed JobState = "failed" JobStateFailed JobState = "failed"
JobStateCancelled JobState = "cancelled" JobStateCancelled JobState = "cancelled"
@@ -154,6 +155,19 @@ const (
DistributionJobStatusDenied DistributionJobStatus = "denied" DistributionJobStatusDenied DistributionJobStatus = "denied"
) )
type RunUpdatePhase string
const (
RunUpdatePhaseQueued RunUpdatePhase = "queued"
RunUpdatePhaseDownloading RunUpdatePhase = "downloading"
RunUpdatePhaseStaged RunUpdatePhase = "staged"
RunUpdatePhaseRestartRequested RunUpdatePhase = "restart-requested"
RunUpdatePhaseActivating RunUpdatePhase = "activating"
RunUpdatePhaseSucceeded RunUpdatePhase = "succeeded"
RunUpdatePhaseRolledBack RunUpdatePhase = "rolled-back"
RunUpdatePhaseFailed RunUpdatePhase = "failed"
)
type LogStreamSource string type LogStreamSource string
const ( const (
@@ -227,6 +241,26 @@ type AuthSession struct {
User User User User
Status string Status string
Message string Message string
ExpiresAt time.Time
}
type AuthSessionStatus string
const (
AuthSessionStatusActive AuthSessionStatus = "active"
AuthSessionStatusRevoked AuthSessionStatus = "revoked"
)
type AuthSessionRecord struct {
ID string
UserID string
TokenHash string
Status AuthSessionStatus
Generation int
IssuedAt time.Time
ExpiresAt time.Time
LastSeenAt time.Time
RevokedAt time.Time
} }
type AIProvider struct { type AIProvider struct {
@@ -305,6 +339,110 @@ type GamePluginRemoteAccess struct {
LogTransfer bool LogTransfer bool
} }
type RuntimeTarget struct {
OS string
Arch string
}
type RuntimeDiscoveryProbe struct {
Key string
Kind string
TargetKey string
Required bool
Expected string
Platforms []string
}
type RuntimeLifecycleProfile struct {
Key string
Mode string
Capabilities []string
ActionRefs PluginLifecycleActions
TransportKeys []string
ClientManagerRef string
Platforms []string
}
type RuntimeDependencyProbe struct {
Key string
Kind string
TargetKey string
Required bool
MinimumVersion string
Platforms []string
}
type RuntimeInstallStep struct {
Type string
TargetKey string
PackageManager string
PackageName string
Version string
DownloadRef string
Checksum string
}
type RuntimeInstallPlan struct {
Key string
Title string
Platforms []string
Steps []RuntimeInstallStep
}
type RuntimeLogSource struct {
Key string
Kind string
TargetKey string
StreamKey string
CursorKind string
RetentionDays int
}
type RuntimeTransportProfile struct {
Key string
Kind string
TargetKey string
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
}
type RuntimeConfigTemplate struct {
Key string
TemplateRef string
OutputRef string
}
type GamePluginRuntimeProfiles struct {
Discovery []RuntimeDiscoveryProbe
LifecycleProfiles []RuntimeLifecycleProfile
DependencyProbes []RuntimeDependencyProbe
InstallPlans []RuntimeInstallPlan
LogSources []RuntimeLogSource
TransportProfiles []RuntimeTransportProfile
ClientManagers []RuntimeClientManagerProfile
}
type GamePluginManifest struct { type GamePluginManifest struct {
ID string ID string
Name string Name string
@@ -320,6 +458,7 @@ type GamePluginManifest struct {
Pages []GamePluginPage Pages []GamePluginPage
AI GamePluginManifestAI AI GamePluginManifestAI
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles
} }
type GamePluginManifestRegistration struct { type GamePluginManifestRegistration struct {
@@ -346,6 +485,7 @@ type GamePlugin struct {
Tags []string Tags []string
AIPurposes []string AIPurposes []string
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles
ValidationViolations []string ValidationViolations []string
Status GamePluginStatus Status GamePluginStatus
} }
@@ -369,6 +509,7 @@ type PluginMarketplacePlugin struct {
Tags []string Tags []string
AIPurposes []string AIPurposes []string
RemoteAccess GamePluginRemoteAccess RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles
ValidationViolations []string ValidationViolations []string
Status GamePluginStatus Status GamePluginStatus
Source string Source string
@@ -446,6 +587,10 @@ type ServerInstance struct {
AdminUserIDs []string AdminUserIDs []string
State ServerInstanceState State ServerInstanceState
ConfigVersion int ConfigVersion int
ConfigKey string
ConfigContent string
ConfigChecksum string
ConfigUpdatedAt time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -482,6 +627,7 @@ type ServerConfig struct {
Format string Format string
Key string Key string
Content string Content string
Checksum string
Source string Source string
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -496,6 +642,7 @@ type ConfigDiffLine struct {
type ServerConfigDiffRequest struct { type ServerConfigDiffRequest struct {
ServerInstanceID string ServerInstanceID string
ExpectedConfigVersion int ExpectedConfigVersion int
ExpectedChecksum string
Key string Key string
ProposedContent string ProposedContent string
ProposedContentInputRef string ProposedContentInputRef string
@@ -504,6 +651,7 @@ type ServerConfigDiffRequest struct {
type ServerConfigDiffPreview struct { type ServerConfigDiffPreview struct {
ServerInstanceID string ServerInstanceID string
ConfigVersion int ConfigVersion int
Checksum string
Key string Key string
CurrentContent string CurrentContent string
ProposedContent string ProposedContent string
@@ -517,6 +665,7 @@ type ServerConfigDiffPreview struct {
type ServerConfigWriteApproval struct { type ServerConfigWriteApproval struct {
ServerInstanceID string ServerInstanceID string
ExpectedConfigVersion int ExpectedConfigVersion int
ExpectedChecksum string
Key string Key string
ProposedContent string ProposedContent string
ProposedContentInputRef string ProposedContentInputRef string
@@ -542,7 +691,9 @@ type FileOperationDispatchRequest struct {
Operation FileOperationKind Operation FileOperationKind
Key string Key string
InputRef string InputRef string
Content string
ExpectedConfigVersion int ExpectedConfigVersion int
ExpectedChecksum string
IdempotencyKey string IdempotencyKey string
} }
@@ -590,6 +741,8 @@ type RunEndpoint struct {
ID string ID string
DisplayName string DisplayName string
Version string Version string
Platform string
Architecture string
Status RunEndpointStatus Status RunEndpointStatus
Capabilities []string Capabilities []string
Capacity RunCapacity Capacity RunCapacity
@@ -601,6 +754,35 @@ type JobProgress struct {
Message string Message string
} }
type JobRetryPolicy struct {
MaxAttempts int
InitialBackoffSeconds int
MaxBackoffSeconds int
}
type JobExecutionInput struct {
WorkspaceScope string
Content string
ExpectedVersion int
ExpectedChecksum string
MaxReadBytes int
RemoteAdapterKey string
RemoteAdapterKind string
TimeoutSeconds int
}
type JobExecutionResult struct {
Kind string
ProcessState string
ExitClassification string
ExitCode int
Version int
Checksum string
SizeBytes int64
AuditSummary string
Content string
}
type Job struct { type Job struct {
ID string ID string
ServerInstanceID string ServerInstanceID string
@@ -612,6 +794,25 @@ type Job struct {
State JobState State JobState
Progress JobProgress Progress JobProgress
ResultRef string ResultRef string
ExecutionInput JobExecutionInput
ExecutionResult JobExecutionResult
RetryPolicy JobRetryPolicy
Attempt int
QueueEligibleAt time.Time
NextAttemptAt time.Time
LeaseTokenHash string
LeaseSessionGen int
AckDeadlineAt time.Time
LeaseExpiresAt time.Time
LastProgressSeq uint64
CancelReason string
CancelRequestedAt time.Time
CancelCompletedAt time.Time
TerminalAt time.Time
TerminalFingerprint string
LastReconciledAt time.Time
ReconcileCount int
ReconcileOutcome string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -631,6 +832,7 @@ type RuntimeBinding struct {
ID string ID string
ServerInstanceID string ServerInstanceID string
PluginID string PluginID string
PluginVersion string
ProfileKey string ProfileKey string
Mode string Mode string
Bindings map[string]string Bindings map[string]string
@@ -640,6 +842,32 @@ type RuntimeBinding struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
type RuntimeBindingUpdate struct {
ProfileKey string
Bindings map[string]string
}
type RuntimeBindingKeyView struct {
Key string
Required bool
Configured bool
Secret bool
}
type RuntimeBindingView struct {
ServerInstanceID string
PluginID string
ProfileKey string
Mode string
Configured bool
Keys []RuntimeBindingKeyView
MissingKeys []string
Status RuntimeBindingStatus
Reason string
CreatedAt time.Time
UpdatedAt time.Time
}
type EncryptedComponentKey struct { type EncryptedComponentKey struct {
ID string ID string
ServerInstanceID string ServerInstanceID string
@@ -679,6 +907,7 @@ type ClientManagerDistribution struct {
ServerInstanceID string ServerInstanceID string
PluginID string PluginID string
ProfileKey string ProfileKey string
Version string
TargetOS string TargetOS string
TargetArch string TargetArch string
RepositoryURL string RepositoryURL string
@@ -703,6 +932,10 @@ type DependencyStatus struct {
State DependencyState State DependencyState
Required bool Required bool
InstallPlanKey string InstallPlanKey string
PlanDigest string
JobID string
Evidence string
CompletedSteps int
Message string Message string
CheckedAt time.Time CheckedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@@ -713,6 +946,7 @@ type ClientManagerBuildJob struct {
ServerInstanceID string ServerInstanceID string
PluginID string PluginID string
ProfileKey string ProfileKey string
Version string
TargetOS string TargetOS string
TargetArch string TargetArch string
RepositoryURL string RepositoryURL string
@@ -732,9 +966,16 @@ type RunUpdateJob struct {
RunEndpointID string RunEndpointID string
ArtifactID string ArtifactID string
Checksum string Checksum string
TargetOS string
TargetArch string
TargetRelease string
PreviousVersion string
JobID string JobID string
IdempotencyKey string IdempotencyKey string
Status DistributionJobStatus Status DistributionJobStatus
Phase RunUpdatePhase
Message string
Rollback bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -805,12 +1046,54 @@ type DependencyJobRequest struct {
ServerInstanceID string ServerInstanceID string
ProbeKey string ProbeKey string
InstallPlanKey string InstallPlanKey string
PlanDigest string
TargetOS string TargetOS string
TargetArch string TargetArch string
IdempotencyKey string IdempotencyKey string
Install bool Install bool
} }
type DependencyProbeView struct {
Key string
Kind string
Required bool
MinimumVersion string
State DependencyState
Evidence string
InstallPlanKey string
}
type DependencyPlanStepView struct {
Type string
TargetKey string
PackageManager string
PackageName string
Version string
DownloadHost string
SizeBytes int64
}
type DependencyPlanView struct {
Key string
Title string
TargetOS string
TargetArch string
Digest string
Steps []DependencyPlanStepView
}
type DependencyCatalog struct {
ServerInstanceID string
PluginID string
PluginVersion string
ProfileKey string
TargetOS string
TargetArch string
Probes []DependencyProbeView
Plans []DependencyPlanView
UpdatedAt time.Time
}
type LogBackfillRequest struct { type LogBackfillRequest struct {
ServerInstanceID string ServerInstanceID string
SourceKey string SourceKey string
@@ -846,6 +1129,12 @@ type UserFilter struct {
Status UserStatus Status UserStatus
} }
type AuthSessionFilter struct {
UserID string
TokenHash string
Status AuthSessionStatus
}
type AIProviderFilter struct { type AIProviderFilter struct {
Kind AIProviderKind Kind AIProviderKind
Status AIProviderStatus Status AIProviderStatus
@@ -968,6 +1257,10 @@ func CopyUser(user User) User {
return user return user
} }
func CopyAuthSessionRecord(session AuthSessionRecord) AuthSessionRecord {
return session
}
func CopyAIProvider(provider AIProvider) AIProvider { func CopyAIProvider(provider AIProvider) AIProvider {
provider.Models = CopyStringSlice(provider.Models) provider.Models = CopyStringSlice(provider.Models)
return provider return provider
@@ -992,6 +1285,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.Tags = CopyStringSlice(plugin.Tags) plugin.Tags = CopyStringSlice(plugin.Tags)
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin return plugin
} }
@@ -1005,6 +1299,7 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla
plugin.Tags = CopyStringSlice(plugin.Tags) plugin.Tags = CopyStringSlice(plugin.Tags)
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin return plugin
} }
@@ -1034,9 +1329,48 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages) manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes) manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess) manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
return manifest return manifest
} }
func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePluginRuntimeProfiles {
profiles.Discovery = append([]RuntimeDiscoveryProbe(nil), profiles.Discovery...)
for i := range profiles.Discovery {
profiles.Discovery[i].Platforms = CopyStringSlice(profiles.Discovery[i].Platforms)
}
profiles.LifecycleProfiles = append([]RuntimeLifecycleProfile(nil), profiles.LifecycleProfiles...)
for i := range profiles.LifecycleProfiles {
profiles.LifecycleProfiles[i].Capabilities = CopyStringSlice(profiles.LifecycleProfiles[i].Capabilities)
profiles.LifecycleProfiles[i].TransportKeys = CopyStringSlice(profiles.LifecycleProfiles[i].TransportKeys)
profiles.LifecycleProfiles[i].Platforms = CopyStringSlice(profiles.LifecycleProfiles[i].Platforms)
}
profiles.DependencyProbes = append([]RuntimeDependencyProbe(nil), profiles.DependencyProbes...)
for i := range profiles.DependencyProbes {
profiles.DependencyProbes[i].Platforms = CopyStringSlice(profiles.DependencyProbes[i].Platforms)
}
profiles.InstallPlans = append([]RuntimeInstallPlan(nil), profiles.InstallPlans...)
for i := range profiles.InstallPlans {
profiles.InstallPlans[i].Platforms = CopyStringSlice(profiles.InstallPlans[i].Platforms)
profiles.InstallPlans[i].Steps = append([]RuntimeInstallStep(nil), profiles.InstallPlans[i].Steps...)
}
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
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)
}
return profiles
}
func CopyGamePluginRemoteAccess(remote GamePluginRemoteAccess) GamePluginRemoteAccess { func CopyGamePluginRemoteAccess(remote GamePluginRemoteAccess) GamePluginRemoteAccess {
remote.Methods = CopyStringSlice(remote.Methods) remote.Methods = CopyStringSlice(remote.Methods)
remote.RunCapabilities = CopyStringSlice(remote.RunCapabilities) remote.RunCapabilities = CopyStringSlice(remote.RunCapabilities)
@@ -1148,6 +1482,17 @@ func CopyRuntimeBinding(binding RuntimeBinding) RuntimeBinding {
return binding return binding
} }
func CopyRuntimeBindingUpdate(update RuntimeBindingUpdate) RuntimeBindingUpdate {
update.Bindings = CopyStringMap(update.Bindings)
return update
}
func CopyRuntimeBindingView(view RuntimeBindingView) RuntimeBindingView {
view.Keys = append([]RuntimeBindingKeyView(nil), view.Keys...)
view.MissingKeys = CopyStringSlice(view.MissingKeys)
return view
}
func CopyEncryptedComponentKey(key EncryptedComponentKey) EncryptedComponentKey { func CopyEncryptedComponentKey(key EncryptedComponentKey) EncryptedComponentKey {
return key return key
} }
@@ -1164,6 +1509,15 @@ func CopyDependencyStatus(status DependencyStatus) DependencyStatus {
return status return status
} }
func CopyDependencyCatalog(catalog DependencyCatalog) DependencyCatalog {
catalog.Probes = append([]DependencyProbeView(nil), catalog.Probes...)
catalog.Plans = append([]DependencyPlanView(nil), catalog.Plans...)
for i := range catalog.Plans {
catalog.Plans[i].Steps = append([]DependencyPlanStepView(nil), catalog.Plans[i].Steps...)
}
return catalog
}
func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob { func CopyClientManagerBuildJob(job ClientManagerBuildJob) ClientManagerBuildJob {
return job return job
} }
+21 -6
View File
@@ -5,7 +5,7 @@ This file defines the first platform resource contracts. Concrete Go domain stru
## Implemented Boundaries ## Implemented Boundaries
- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values. - Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values.
- DTO responses expose `apiKeyRef` for AI providers but never raw key material. - DTO responses expose AI-provider secret presence only (`apiKeyConfigured`), never the stored reference or raw key material.
- Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work. - Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work.
- `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs. - `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs.
- Log stream metadata records the selected body backend. The current durable local body backend uses `local-segments`; future production adapters should target log-optimized stores such as `clickhouse`, `loki`, `opensearch`, or `elasticsearch` rather than row-per-line relational tables. - Log stream metadata records the selected body backend. The current durable local body backend uses `local-segments`; future production adapters should target log-optimized stores such as `clickhouse`, `loki`, `opensearch`, or `elasticsearch` rather than row-per-line relational tables.
@@ -27,7 +27,8 @@ This file defines the first platform resource contracts. Concrete Go domain stru
- `name`: display name. - `name`: display name.
- `kind`: `openai-compatible`, `openai`, `claude`, `gemini`, `ollama`, or `custom`. - `kind`: `openai-compatible`, `openai`, `claude`, `gemini`, `ollama`, or `custom`.
- `baseUrl`: provider or relay base URL. - `baseUrl`: provider or relay base URL.
- `apiKeyRef`: secret reference, never the raw key. - `apiKeyRef`: platform-owned secret reference accepted on writes and never returned by response DTOs.
- `apiKeyConfigured`: response-only presence flag.
- `models`: allowed model IDs. - `models`: allowed model IDs.
- `defaultModel`: optional default model. - `defaultModel`: optional default model.
- `relayMode`: `direct`, `relay`, or `local`. - `relayMode`: `direct`, `relay`, or `local`.
@@ -87,18 +88,30 @@ Runtime profile and distribution permissions are declared by plugins, then gated
Run control hello can include server/component identity from a generated package config. When `serverInstanceId`, `pluginId`, `componentKind`, `componentKey`, and `keyGeneration` are present, platform authenticates the provided key against the current encrypted component key before issuing a session token. Stale generations after reset are rejected without returning raw key material. Run control hello can include server/component identity from a generated package config. When `serverInstanceId`, `pluginId`, `componentKind`, `componentKey`, and `keyGeneration` are present, platform authenticates the provided key against the current encrypted component key before issuing a session token. Stale generations after reset are rejected without returning raw key material.
Run sessions persist only a token hash, generation, status, expiry, capability fingerprint, signed-request policy, and bounded replay nonce history. Component-authenticated Run sessions require HMAC-SHA256 HTTP envelopes over method, path, timestamp, nonce, and request-body hash; timestamps outside five minutes and repeated nonces are rejected.
## AuthSessionRecord
- `tokenHash`: SHA-256 verifier; raw bearer tokens are never persisted.
- `userId`: owning user.
- `status`: `active` or `revoked`.
- `generation`: monotonically increasing user session generation.
- `issuedAt`, `expiresAt`, `lastSeenAt`, `revokedAt`: durable lifecycle timestamps.
## RuntimeBinding ## RuntimeBinding
- `id`: runtime binding ID. - `id`: runtime binding ID.
- `serverInstanceId`: server instance using the binding. - `serverInstanceId`: server instance using the binding.
- `pluginId`: installed plugin that declared the logical runtime profile. - `pluginId` and `pluginVersion`: installed plugin contract that declared the logical runtime profile.
- `profileKey`: declared lifecycle/runtime profile key. - `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`, `ftp-only`, or `custom-client`.
- `bindings`: logical binding keys to operator-provided settings. - `bindings`: logical binding keys to operator-provided settings.
- `missingKeys`: logical keys that must be completed before dependent actions are available. - `missingKeys`: logical keys that must be completed before dependent actions are available.
- `status`: `complete`, `incomplete`, or `invalid`. - `status`: `complete` or `incomplete`.
Bindings are used for action gating and run-side profile resolution. API responses and logs must use logical keys and safe reasons only; they must not expose raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, or component auth keys. 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`.
Bindings are used for action gating and future run-side profile resolution. File and MySQL metadata snapshots include them so a platform restart does not make a configured server appear complete or lose its selected profile. API responses expose only logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never expose stored binding values, raw host paths, direct sockets, FTP/RCON passwords, SQL DSNs, component auth keys, or internal secret locations.
## Runtime Component Keys And Distributions ## Runtime Component Keys And Distributions
@@ -106,7 +119,7 @@ Bindings are used for action gating and run-side profile resolution. API respons
- `RunDistribution`: records a generated run package for one server, target OS/architecture, package format, artifact ID, checksum, key generation, secret ref, and status. - `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. - `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. - `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, run endpoint, artifact ID, checksum, job ID, idempotency key, 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 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.
@@ -121,6 +134,8 @@ Run and client-manager keys are isolated singleton credentials. Reset replaces t
- `required`: whether the probe is required for the runtime profile. - `required`: whether the probe is required for the runtime profile.
- `installPlanKey`: optional typed install plan key. - `installPlanKey`: optional typed install plan key.
- `message`: bounded safe status. - `message`: bounded safe status.
- `planDigest`: deterministic SHA-256 digest of the declared target-specific probe/plan and logical binding generation; install approval must match it exactly.
- `evidence`, `completedSteps`, `jobId`: bounded terminal execution projection; no command output, path, credential, or private binding is stored in the projection.
- `checkedAt`, `updatedAt`: observation times. - `checkedAt`, `updatedAt`: observation times.
Dependency checks and installs are queued as run jobs with logical `dependencies/...` or `dependencies/install/...` target keys. Install jobs must use typed plugin-declared plans and must not carry arbitrary shell snippets. Dependency checks and installs are queued as run jobs with logical `dependencies/...` or `dependencies/install/...` target keys. Install jobs must use typed plugin-declared plans and must not carry arbitrary shell snippets.
+7
View File
@@ -6,12 +6,14 @@ const (
ServerLifecycleActionCreate ServerLifecycleAction = "create" ServerLifecycleActionCreate ServerLifecycleAction = "create"
ServerLifecycleActionStart ServerLifecycleAction = "start" ServerLifecycleActionStart ServerLifecycleAction = "start"
ServerLifecycleActionStop ServerLifecycleAction = "stop" ServerLifecycleActionStop ServerLifecycleAction = "stop"
ServerLifecycleActionStatus ServerLifecycleAction = "status"
) )
const ( const (
LifecycleCapabilityInstall = "process.install" LifecycleCapabilityInstall = "process.install"
LifecycleCapabilityStart = "process.start" LifecycleCapabilityStart = "process.start"
LifecycleCapabilityStop = "process.stop" LifecycleCapabilityStop = "process.stop"
LifecycleCapabilityStatus = "process.status"
) )
type ServerLifecycleCreate struct { type ServerLifecycleCreate struct {
@@ -21,6 +23,8 @@ type ServerLifecycleCreate struct {
Name string Name string
OwnerUserID string OwnerUserID string
IdempotencyKey string IdempotencyKey string
ProfileKey string
Bindings map[string]string
} }
type ServerLifecycleCommand struct { type ServerLifecycleCommand struct {
@@ -44,12 +48,15 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
return LifecycleCapabilityStart return LifecycleCapabilityStart
case ServerLifecycleActionStop: case ServerLifecycleActionStop:
return LifecycleCapabilityStop return LifecycleCapabilityStop
case ServerLifecycleActionStatus:
return LifecycleCapabilityStatus
default: default:
return "" return ""
} }
} }
func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate { func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate {
create.Bindings = CopyStringMap(create.Bindings)
return create return create
} }
+267
View File
@@ -0,0 +1,267 @@
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"`
}
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)
return 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}
}
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)}
}
+8
View File
@@ -23,6 +23,9 @@ type RunControlHelloRequest struct {
Version string `json:"version"` Version string `json:"version"`
Status domain.RunEndpointStatus `json:"status"` Status domain.RunEndpointStatus `json:"status"`
Platform string `json:"platform,omitempty"` Platform string `json:"platform,omitempty"`
Architecture string `json:"architecture,omitempty"`
UpdateJobID string `json:"updateJobId,omitempty"`
UpdateOutcome string `json:"updateOutcome,omitempty"`
CapabilityReport RunCapabilityReport `json:"capabilityReport"` CapabilityReport RunCapabilityReport `json:"capabilityReport"`
Capacity RunCapacityResponse `json:"capacity"` Capacity RunCapacityResponse `json:"capacity"`
} }
@@ -33,6 +36,7 @@ type RunControlHelloResponse struct {
SessionToken string `json:"sessionToken"` SessionToken string `json:"sessionToken"`
ServerTime time.Time `json:"serverTime"` ServerTime time.Time `json:"serverTime"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"` HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
FeatureFlags []string `json:"featureFlags,omitempty"` FeatureFlags []string `json:"featureFlags,omitempty"`
} }
@@ -66,6 +70,9 @@ func (request RunControlHelloRequest) ToDomain() domain.RunControlHello {
Version: request.Version, Version: request.Version,
Status: request.Status, Status: request.Status,
Platform: request.Platform, Platform: request.Platform,
Architecture: request.Architecture,
UpdateJobID: request.UpdateJobID,
UpdateOutcome: request.UpdateOutcome,
CapabilityReport: domain.RunCapabilityReport{ CapabilityReport: domain.RunCapabilityReport{
Capabilities: domain.CopyStringSlice(request.CapabilityReport.Capabilities), Capabilities: domain.CopyStringSlice(request.CapabilityReport.Capabilities),
Fingerprint: request.CapabilityReport.Fingerprint, Fingerprint: request.CapabilityReport.Fingerprint,
@@ -93,6 +100,7 @@ func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHe
SessionToken: result.SessionToken, SessionToken: result.SessionToken,
ServerTime: result.ServerTime, ServerTime: result.ServerTime,
HeartbeatIntervalSeconds: result.HeartbeatIntervalSeconds, HeartbeatIntervalSeconds: result.HeartbeatIntervalSeconds,
SessionExpiresAt: result.SessionExpiresAt,
FeatureFlags: result.FeatureFlags, FeatureFlags: result.FeatureFlags,
} }
} }
+145
View File
@@ -12,6 +12,52 @@ type RunDistributionGenerateRequest struct {
IdempotencyKey string `json:"idempotencyKey,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"`
} }
type RuntimeBindingUpdateRequest struct {
ProfileKey string `json:"profileKey"`
Bindings map[string]string `json:"bindings,omitempty"`
}
type RuntimeBindingKeyResponse struct {
Key string `json:"key"`
Required bool `json:"required"`
Configured bool `json:"configured"`
Secret bool `json:"secret"`
}
type RuntimeBindingResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey,omitempty"`
Mode string `json:"mode,omitempty"`
Configured bool `json:"configured"`
Keys []RuntimeBindingKeyResponse `json:"keys"`
MissingKeys []string `json:"missingKeys"`
Status domain.RuntimeBindingStatus `json:"status"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
func (request RuntimeBindingUpdateRequest) ToDomain() domain.RuntimeBindingUpdate {
return domain.RuntimeBindingUpdate{ProfileKey: request.ProfileKey, Bindings: domain.CopyStringMap(request.Bindings)}
}
func RuntimeBindingFromDomain(view domain.RuntimeBindingView) RuntimeBindingResponse {
view = domain.CopyRuntimeBindingView(view)
keys := make([]RuntimeBindingKeyResponse, len(view.Keys))
for i, key := range view.Keys {
keys[i] = RuntimeBindingKeyResponse{Key: key.Key, Required: key.Required, Configured: key.Configured, Secret: key.Secret}
}
if keys == nil {
keys = []RuntimeBindingKeyResponse{}
}
missing := view.MissingKeys
if missing == nil {
missing = []string{}
}
return RuntimeBindingResponse{ServerInstanceID: view.ServerInstanceID, PluginID: view.PluginID, ProfileKey: view.ProfileKey, Mode: view.Mode, Configured: view.Configured, Keys: keys, MissingKeys: missing, Status: view.Status, Reason: view.Reason, CreatedAt: view.CreatedAt, UpdatedAt: view.UpdatedAt}
}
type RunUpdateRequest struct { type RunUpdateRequest struct {
ArtifactID string `json:"artifactId"` ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
@@ -21,6 +67,7 @@ type RunUpdateRequest struct {
type DependencyJobRequest struct { type DependencyJobRequest struct {
ProbeKey string `json:"probeKey"` ProbeKey string `json:"probeKey"`
InstallPlanKey string `json:"installPlanKey,omitempty"` InstallPlanKey string `json:"installPlanKey,omitempty"`
PlanDigest string `json:"planDigest,omitempty"`
TargetOS string `json:"targetOs,omitempty"` TargetOS string `json:"targetOs,omitempty"`
TargetArch string `json:"targetArch,omitempty"` TargetArch string `json:"targetArch,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"`
@@ -103,6 +150,7 @@ type ClientManagerDistributionResponse struct {
ServerInstanceID string `json:"serverInstanceId"` ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"` PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"` ProfileKey string `json:"profileKey"`
Version string `json:"version,omitempty"`
TargetOS string `json:"targetOs"` TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"` TargetArch string `json:"targetArch"`
RepositoryURL string `json:"repositoryUrl"` RepositoryURL string `json:"repositoryUrl"`
@@ -127,16 +175,62 @@ type DependencyStatusResponse struct {
State string `json:"state"` State string `json:"state"`
Required bool `json:"required"` Required bool `json:"required"`
InstallPlanKey string `json:"installPlanKey,omitempty"` InstallPlanKey string `json:"installPlanKey,omitempty"`
PlanDigest string `json:"planDigest,omitempty"`
JobID string `json:"jobId,omitempty"`
Evidence string `json:"evidence,omitempty"`
CompletedSteps int `json:"completedSteps,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
CheckedAt time.Time `json:"checkedAt"` CheckedAt time.Time `json:"checkedAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type DependencyProbeResponse struct {
Key string `json:"key"`
Kind string `json:"kind"`
Required bool `json:"required"`
MinimumVersion string `json:"minimumVersion,omitempty"`
State string `json:"state"`
Evidence string `json:"evidence,omitempty"`
InstallPlanKey string `json:"installPlanKey,omitempty"`
}
type DependencyPlanStepResponse struct {
Type string `json:"type"`
TargetKey string `json:"targetKey"`
PackageManager string `json:"packageManager,omitempty"`
PackageName string `json:"packageName,omitempty"`
Version string `json:"version,omitempty"`
DownloadHost string `json:"downloadHost,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
}
type DependencyPlanResponse struct {
Key string `json:"key"`
Title string `json:"title"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Digest string `json:"digest"`
Steps []DependencyPlanStepResponse `json:"steps"`
}
type DependencyCatalogResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Probes []DependencyProbeResponse `json:"probes"`
Plans []DependencyPlanResponse `json:"plans"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ClientManagerBuildJobResponse struct { type ClientManagerBuildJobResponse struct {
ID string `json:"id"` ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"` ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"` PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"` ProfileKey string `json:"profileKey"`
Version string `json:"version,omitempty"`
TargetOS string `json:"targetOs"` TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"` TargetArch string `json:"targetArch"`
RepositoryURL string `json:"repositoryUrl"` RepositoryURL string `json:"repositoryUrl"`
@@ -156,13 +250,25 @@ type RunUpdateJobResponse struct {
RunEndpointID string `json:"runEndpointId"` RunEndpointID string `json:"runEndpointId"`
ArtifactID string `json:"artifactId"` ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
TargetRelease string `json:"targetRelease,omitempty"`
PreviousVersion string `json:"previousVersion,omitempty"`
JobID string `json:"jobId,omitempty"` JobID string `json:"jobId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Phase string `json:"phase"`
Message string `json:"message,omitempty"`
Rollback bool `json:"rollback"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type RunUpdateJobListResponse struct {
Items []RunUpdateJobResponse `json:"items"`
Count int `json:"count"`
}
func (request RunDistributionGenerateRequest) ToDomain(serverInstanceID string) domain.RunDistributionGenerateRequest { func (request RunDistributionGenerateRequest) ToDomain(serverInstanceID string) domain.RunDistributionGenerateRequest {
return domain.RunDistributionGenerateRequest{ return domain.RunDistributionGenerateRequest{
ServerInstanceID: serverInstanceID, ServerInstanceID: serverInstanceID,
@@ -186,6 +292,7 @@ func (request DependencyJobRequest) ToDomain(serverInstanceID string, install bo
ServerInstanceID: serverInstanceID, ServerInstanceID: serverInstanceID,
ProbeKey: request.ProbeKey, ProbeKey: request.ProbeKey,
InstallPlanKey: request.InstallPlanKey, InstallPlanKey: request.InstallPlanKey,
PlanDigest: request.PlanDigest,
TargetOS: request.TargetOS, TargetOS: request.TargetOS,
TargetArch: request.TargetArch, TargetArch: request.TargetArch,
IdempotencyKey: request.IdempotencyKey, IdempotencyKey: request.IdempotencyKey,
@@ -280,6 +387,7 @@ func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistri
ServerInstanceID: distribution.ServerInstanceID, ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID, PluginID: distribution.PluginID,
ProfileKey: distribution.ProfileKey, ProfileKey: distribution.ProfileKey,
Version: distribution.Version,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
RepositoryURL: distribution.RepositoryURL, RepositoryURL: distribution.RepositoryURL,
@@ -306,18 +414,40 @@ func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus
State: string(status.State), State: string(status.State),
Required: status.Required, Required: status.Required,
InstallPlanKey: status.InstallPlanKey, InstallPlanKey: status.InstallPlanKey,
PlanDigest: status.PlanDigest,
JobID: status.JobID,
Evidence: status.Evidence,
CompletedSteps: status.CompletedSteps,
Message: status.Message, Message: status.Message,
CheckedAt: status.CheckedAt, CheckedAt: status.CheckedAt,
UpdatedAt: status.UpdatedAt, UpdatedAt: status.UpdatedAt,
} }
} }
func DependencyCatalogFromDomain(catalog domain.DependencyCatalog) DependencyCatalogResponse {
catalog = domain.CopyDependencyCatalog(catalog)
probes := make([]DependencyProbeResponse, len(catalog.Probes))
for i, probe := range catalog.Probes {
probes[i] = DependencyProbeResponse{Key: probe.Key, Kind: probe.Kind, Required: probe.Required, MinimumVersion: probe.MinimumVersion, State: string(probe.State), Evidence: probe.Evidence, InstallPlanKey: probe.InstallPlanKey}
}
plans := make([]DependencyPlanResponse, len(catalog.Plans))
for i, plan := range catalog.Plans {
steps := make([]DependencyPlanStepResponse, len(plan.Steps))
for j, step := range plan.Steps {
steps[j] = DependencyPlanStepResponse{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadHost: step.DownloadHost, SizeBytes: step.SizeBytes}
}
plans[i] = DependencyPlanResponse{Key: plan.Key, Title: plan.Title, TargetOS: plan.TargetOS, TargetArch: plan.TargetArch, Digest: plan.Digest, Steps: steps}
}
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 { func ClientManagerBuildJobFromDomain(job domain.ClientManagerBuildJob) ClientManagerBuildJobResponse {
return ClientManagerBuildJobResponse{ return ClientManagerBuildJobResponse{
ID: job.ID, ID: job.ID,
ServerInstanceID: job.ServerInstanceID, ServerInstanceID: job.ServerInstanceID,
PluginID: job.PluginID, PluginID: job.PluginID,
ProfileKey: job.ProfileKey, ProfileKey: job.ProfileKey,
Version: job.Version,
TargetOS: job.TargetOS, TargetOS: job.TargetOS,
TargetArch: job.TargetArch, TargetArch: job.TargetArch,
RepositoryURL: job.RepositoryURL, RepositoryURL: job.RepositoryURL,
@@ -339,10 +469,25 @@ func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse {
RunEndpointID: job.RunEndpointID, RunEndpointID: job.RunEndpointID,
ArtifactID: job.ArtifactID, ArtifactID: job.ArtifactID,
Checksum: job.Checksum, Checksum: job.Checksum,
TargetOS: job.TargetOS,
TargetArch: job.TargetArch,
TargetRelease: job.TargetRelease,
PreviousVersion: job.PreviousVersion,
JobID: job.JobID, JobID: job.JobID,
IdempotencyKey: job.IdempotencyKey, IdempotencyKey: job.IdempotencyKey,
Status: string(job.Status), Status: string(job.Status),
Phase: string(job.Phase),
Message: job.Message,
Rollback: job.Rollback,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt, UpdatedAt: job.UpdatedAt,
} }
} }
func RunUpdateJobListFromDomain(jobs []domain.RunUpdateJob) RunUpdateJobListResponse {
items := make([]RunUpdateJobResponse, len(jobs))
for i, job := range jobs {
items[i] = RunUpdateJobFromDomain(job)
}
return RunUpdateJobListResponse{Items: items, Count: len(items)}
}
+186 -8
View File
@@ -17,8 +17,14 @@ type RunJobAssignmentResponse struct {
State domain.JobState `json:"state"` State domain.JobState `json:"state"`
Progress JobProgressBody `json:"progress"` Progress JobProgressBody `json:"progress"`
ResultRef string `json:"resultRef,omitempty"` ResultRef string `json:"resultRef,omitempty"`
ExecutionInput RunJobExecutionInputBody `json:"executionInput,omitempty"`
LeaseToken string `json:"leaseToken"` LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"` Attempt int `json:"attempt"`
MaxAttempts int `json:"maxAttempts"`
AckDeadlineAt time.Time `json:"ackDeadlineAt,omitempty"`
LeaseExpiresAt time.Time `json:"leaseExpiresAt,omitempty"`
NextAttemptAt time.Time `json:"nextAttemptAt,omitempty"`
ProgressSequence uint64 `json:"progressSequence,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -81,6 +87,31 @@ type RunJobResultRequest struct {
ResultRef string `json:"resultRef,omitempty"` ResultRef string `json:"resultRef,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
Retryable bool `json:"retryable,omitempty"`
ExecutionResult RunJobExecutionResultBody `json:"executionResult,omitempty"`
}
type RunJobExecutionInputBody struct {
WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
type RunJobExecutionResultBody struct {
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
Content string `json:"content,omitempty"`
} }
type RunJobResultResponse struct { type RunJobResultResponse struct {
@@ -106,6 +137,7 @@ type DistributionBuildInputResponse struct {
ProfileKey string `json:"profileKey,omitempty"` ProfileKey string `json:"profileKey,omitempty"`
TargetOS string `json:"targetOs"` TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"` TargetArch string `json:"targetArch"`
TargetRelease string `json:"targetRelease"`
PackageFormat string `json:"packageFormat"` PackageFormat string `json:"packageFormat"`
RepositoryURL string `json:"repositoryUrl,omitempty"` RepositoryURL string `json:"repositoryUrl,omitempty"`
SourceRevision string `json:"sourceRevision,omitempty"` SourceRevision string `json:"sourceRevision,omitempty"`
@@ -116,6 +148,89 @@ type DistributionBuildInputResponse struct {
AuthKey string `json:"authKey"` AuthKey string `json:"authKey"`
} }
type DependencyExecutionInputRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type DependencyExecutionInputResponse struct {
JobID string `json:"jobId"`
ServerInstanceID string `json:"serverInstanceId"`
RunEndpointID string `json:"runEndpointId"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
PlanDigest string `json:"planDigest"`
Probe RuntimeDependencyProbeBody `json:"probe,omitempty"`
Plan RuntimeInstallPlanBody `json:"plan,omitempty"`
Bindings map[string]string `json:"bindings"`
}
type RunUpdateInputRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type RunUpdateInputResponse struct {
JobID string `json:"jobId"`
ServerInstanceID string `json:"serverInstanceId"`
RunEndpointID string `json:"runEndpointId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
PackageFormat string `json:"packageFormat"`
ExecutableName string `json:"executableName"`
TargetRelease string `json:"targetRelease"`
ChunkSizeBytes int `json:"chunkSizeBytes"`
}
type RunUpdateChunkRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Offset int64 `json:"offset"`
Length int `json:"length"`
}
type RunUpdateChunkResponse struct {
JobID string `json:"jobId"`
ArtifactID string `json:"artifactId"`
Offset int64 `json:"offset"`
TotalBytes int64 `json:"totalBytes"`
Checksum string `json:"checksum"`
Payload []byte `json:"payload"`
Complete bool `json:"complete"`
}
type RunUpdateHealthRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Outcome string `json:"outcome"`
Version string `json:"version"`
}
type RunUpdateHealthResponse struct {
Accepted bool `json:"accepted"`
JobID string `json:"jobId"`
Phase domain.RunUpdatePhase `json:"phase"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobCancelRequestBody struct { type RunJobCancelRequestBody struct {
JobID string `json:"jobId"` JobID string `json:"jobId"`
Reason string `json:"reason"` Reason string `json:"reason"`
@@ -126,6 +241,8 @@ type RunJobCancelRequestResponse struct {
JobID string `json:"jobId"` JobID string `json:"jobId"`
Reason string `json:"reason"` Reason string `json:"reason"`
RequestedAt time.Time `json:"requestedAt"` RequestedAt time.Time `json:"requestedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
State domain.JobState `json:"state"`
} }
type RunJobCancelPollRequest struct { type RunJobCancelPollRequest struct {
@@ -133,6 +250,7 @@ type RunJobCancelPollRequest struct {
SessionToken string `json:"sessionToken"` SessionToken string `json:"sessionToken"`
JobID string `json:"jobId,omitempty"` JobID string `json:"jobId,omitempty"`
LeaseToken string `json:"leaseToken,omitempty"` LeaseToken string `json:"leaseToken,omitempty"`
Attempt int `json:"attempt"`
} }
type RunJobCancelPollResponse struct { type RunJobCancelPollResponse struct {
@@ -145,17 +263,23 @@ type RunJobCancelPollResponse struct {
ServerTime time.Time `json:"serverTime"` ServerTime time.Time `json:"serverTime"`
} }
type RunJobReconcileEntry struct {
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type RunJobReconcileRequest struct { type RunJobReconcileRequest struct {
RunEndpointID string `json:"runEndpointId"` RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"` SessionToken string `json:"sessionToken"`
ActiveJobIDs []string `json:"activeJobIds"` ActiveJobs []RunJobReconcileEntry `json:"activeJobs"`
} }
type RunJobReconcileResponse struct { type RunJobReconcileResponse struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"` RunEndpointID string `json:"runEndpointId"`
ActiveJobs []RunJobAssignmentResponse `json:"activeJobs"` ConfirmedJobs []RunJobAssignmentResponse `json:"confirmedJobs"`
UnknownJobIDs []string `json:"unknownJobIds"` DiscardJobIDs []string `json:"discardJobIds"`
ServerTime time.Time `json:"serverTime"` ServerTime time.Time `json:"serverTime"`
} }
@@ -203,6 +327,8 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
ResultRef: request.ResultRef, ResultRef: request.ResultRef,
Message: request.Message, Message: request.Message,
ErrorCode: request.ErrorCode, ErrorCode: request.ErrorCode,
Retryable: request.Retryable,
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content},
} }
} }
@@ -216,6 +342,22 @@ func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuild
} }
} }
func (request DependencyExecutionInputRequest) ToDomain() domain.DependencyExecutionInputRequest {
return domain.DependencyExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
}
func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest {
return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
}
func (request RunUpdateChunkRequest) ToDomain() domain.RunUpdateChunkRequest {
return domain.RunUpdateChunkRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Offset: request.Offset, Length: request.Length}
}
func (request RunUpdateHealthRequest) ToDomain() domain.RunUpdateHealthReport {
return domain.RunUpdateHealthReport{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Outcome: request.Outcome, Version: request.Version}
}
func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest { func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest {
return domain.RunJobCancelRequest{ return domain.RunJobCancelRequest{
JobID: request.JobID, JobID: request.JobID,
@@ -229,14 +371,19 @@ func (request RunJobCancelPollRequest) ToDomain() domain.RunJobCancelPoll {
SessionToken: request.SessionToken, SessionToken: request.SessionToken,
JobID: request.JobID, JobID: request.JobID,
LeaseToken: request.LeaseToken, LeaseToken: request.LeaseToken,
Attempt: request.Attempt,
} }
} }
func (request RunJobReconcileRequest) ToDomain() domain.RunJobReconcile { func (request RunJobReconcileRequest) ToDomain() domain.RunJobReconcile {
active := make([]domain.RunJobReconcileEntry, len(request.ActiveJobs))
for i, entry := range request.ActiveJobs {
active[i] = domain.RunJobReconcileEntry{JobID: entry.JobID, LeaseToken: entry.LeaseToken, Attempt: entry.Attempt}
}
return domain.RunJobReconcile{ return domain.RunJobReconcile{
RunEndpointID: request.RunEndpointID, RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken, SessionToken: request.SessionToken,
ActiveJobIDs: domain.CopyStringSlice(request.ActiveJobIDs), ActiveJobs: active,
} }
} }
@@ -286,6 +433,7 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr
ProfileKey: input.ProfileKey, ProfileKey: input.ProfileKey,
TargetOS: input.TargetOS, TargetOS: input.TargetOS,
TargetArch: input.TargetArch, TargetArch: input.TargetArch,
TargetRelease: input.TargetRelease,
PackageFormat: input.PackageFormat, PackageFormat: input.PackageFormat,
RepositoryURL: input.RepositoryURL, RepositoryURL: input.RepositoryURL,
SourceRevision: input.SourceRevision, SourceRevision: input.SourceRevision,
@@ -297,12 +445,36 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr
} }
} }
func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) DependencyExecutionInputResponse {
input = domain.CopyDependencyExecutionInput(input)
steps := make([]RuntimeInstallStepBody, len(input.Plan.Steps))
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}
}
func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse {
return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes}
}
func RunUpdateChunkFromDomain(chunk domain.RunUpdateChunk) RunUpdateChunkResponse {
chunk = domain.CopyRunUpdateChunk(chunk)
return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
}
func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse {
return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime}
}
func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse { func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse {
return RunJobCancelRequestResponse{ return RunJobCancelRequestResponse{
Accepted: result.Accepted, Accepted: result.Accepted,
JobID: result.JobID, JobID: result.JobID,
Reason: result.Reason, Reason: result.Reason,
RequestedAt: result.RequestedAt, RequestedAt: result.RequestedAt,
CompletedAt: result.CompletedAt,
State: result.State,
} }
} }
@@ -320,15 +492,15 @@ func RunJobCancelPollFromDomain(result domain.RunJobCancelPollResult) RunJobCanc
func RunJobReconcileFromDomain(result domain.RunJobReconcileResult) RunJobReconcileResponse { func RunJobReconcileFromDomain(result domain.RunJobReconcileResult) RunJobReconcileResponse {
result = domain.CopyRunJobReconcileResult(result) result = domain.CopyRunJobReconcileResult(result)
items := make([]RunJobAssignmentResponse, len(result.ActiveJobs)) items := make([]RunJobAssignmentResponse, len(result.ConfirmedJobs))
for i, assignment := range result.ActiveJobs { for i, assignment := range result.ConfirmedJobs {
items[i] = RunJobAssignmentFromDomain(assignment) items[i] = RunJobAssignmentFromDomain(assignment)
} }
return RunJobReconcileResponse{ return RunJobReconcileResponse{
Accepted: result.Accepted, Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID, RunEndpointID: result.RunEndpointID,
ActiveJobs: items, ConfirmedJobs: items,
UnknownJobIDs: result.UnknownJobIDs, DiscardJobIDs: result.DiscardJobIDs,
ServerTime: result.ServerTime, ServerTime: result.ServerTime,
} }
} }
@@ -353,8 +525,14 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
State: assignment.State, State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress), Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef, 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},
LeaseToken: assignment.LeaseToken, LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt, Attempt: assignment.Attempt,
MaxAttempts: assignment.MaxAttempts,
AckDeadlineAt: assignment.AckDeadlineAt,
LeaseExpiresAt: assignment.LeaseExpiresAt,
NextAttemptAt: assignment.NextAttemptAt,
ProgressSequence: assignment.ProgressSequence,
CreatedAt: assignment.CreatedAt, CreatedAt: assignment.CreatedAt,
UpdatedAt: assignment.UpdatedAt, UpdatedAt: assignment.UpdatedAt,
} }
+165
View File
@@ -0,0 +1,165 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type MetricSampleBody struct {
ID string `json:"id,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
Online bool `json:"online"`
PlayerCount *int `json:"playerCount,omitempty"`
MaxPlayers *int `json:"maxPlayers,omitempty"`
TPS *float64 `json:"tps,omitempty"`
LatencyMS *float64 `json:"latencyMs,omitempty"`
CPUPercent *float64 `json:"cpuPercent,omitempty"`
MemoryPercent *float64 `json:"memoryPercent,omitempty"`
DiskPercent *float64 `json:"diskPercent,omitempty"`
Source string `json:"source"`
CollectedAt time.Time `json:"collectedAt"`
}
type MetricBatchIngestRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
Samples []MetricSampleBody `json:"samples"`
}
type MetricBatchIngestResponse struct {
Accepted bool `json:"accepted"`
AcceptedCount int `json:"acceptedCount"`
LatestAt time.Time `json:"latestAt"`
ServerTime time.Time `json:"serverTime"`
}
type MetricSampleListResponse struct {
Items []MetricSampleBody `json:"items"`
Count int `json:"count"`
}
type BackupCreateRequest struct {
ID string `json:"id,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
State domain.BackupState `json:"state,omitempty"`
RetentionUntil time.Time `json:"retentionUntil,omitempty"`
}
type BackupResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
State domain.BackupState `json:"state"`
RecoveryStatus string `json:"recoveryStatus,omitempty"`
RetentionUntil time.Time `json:"retentionUntil,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupListResponse struct {
Items []BackupResponse `json:"items"`
Count int `json:"count"`
}
type RemoteAdapterDeclarationResponse struct {
Key string `json:"key"`
Kind domain.RemoteAdapterKind `json:"kind"`
TargetKeys []string `json:"targetKeys"`
Capabilities []string `json:"capabilities"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxAttempts int `json:"maxAttempts"`
}
type RemoteAdapterDeclarationListResponse struct {
Items []RemoteAdapterDeclarationResponse `json:"items"`
Count int `json:"count"`
}
type RemoteAdapterRequestBody struct {
DeclarationKey string `json:"declarationKey"`
TargetKey string `json:"targetKey"`
Capability string `json:"capability"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type RemoteAdapterResponse struct {
RequestID string `json:"requestId"`
ServerInstanceID string `json:"serverInstanceId"`
DeclarationKey string `json:"declarationKey"`
TargetKey string `json:"targetKey"`
Kind domain.RemoteAdapterKind `json:"kind"`
Status string `json:"status"`
Retryable bool `json:"retryable"`
Message string `json:"message"`
ResultRef string `json:"resultRef,omitempty"`
AuditEventID string `json:"auditEventId,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
func (request MetricBatchIngestRequest) ToDomain() domain.MetricBatchIngest {
samples := make([]domain.MetricSample, len(request.Samples))
for i, sample := range request.Samples {
samples[i] = metricSampleToDomain(sample)
}
return domain.MetricBatchIngest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, Samples: samples}
}
func MetricBatchIngestFromDomain(result domain.MetricBatchIngestResult) MetricBatchIngestResponse {
return MetricBatchIngestResponse{Accepted: result.Accepted, AcceptedCount: result.AcceptedCount, LatestAt: result.LatestAt, ServerTime: result.ServerTime}
}
func MetricSampleListFromDomain(samples []domain.MetricSample) MetricSampleListResponse {
items := make([]MetricSampleBody, len(samples))
for i, sample := range samples {
items[i] = metricSampleFromDomain(sample)
}
return MetricSampleListResponse{Items: items, Count: len(items)}
}
func (request BackupCreateRequest) ToDomain() domain.BackupRecord {
return domain.BackupRecord{ID: request.ID, ServerInstanceID: request.ServerInstanceID, ArtifactID: request.ArtifactID, Checksum: request.Checksum, SizeBytes: request.SizeBytes, State: request.State, RetentionUntil: request.RetentionUntil}
}
func BackupFromDomain(record domain.BackupRecord) BackupResponse {
return BackupResponse{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
func BackupListFromDomain(records []domain.BackupRecord) BackupListResponse {
items := make([]BackupResponse, len(records))
for i, record := range records {
items[i] = BackupFromDomain(record)
}
return BackupListResponse{Items: items, Count: len(items)}
}
func RemoteAdapterDeclarationsFromDomain(declarations []domain.RemoteAdapterDeclaration) RemoteAdapterDeclarationListResponse {
items := make([]RemoteAdapterDeclarationResponse, len(declarations))
for i, declaration := range declarations {
items[i] = RemoteAdapterDeclarationResponse{Key: declaration.Key, Kind: declaration.Kind, TargetKeys: domain.CopyStringSlice(declaration.TargetKeys), Capabilities: domain.CopyStringSlice(declaration.Capabilities), TimeoutSeconds: declaration.TimeoutSeconds, MaxAttempts: declaration.MaxAttempts}
}
return RemoteAdapterDeclarationListResponse{Items: items, Count: len(items)}
}
func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain.RemoteAdapterRequest {
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey}
}
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, AuditEventID: result.AuditEventID, CompletedAt: result.CompletedAt}
}
func metricSampleToDomain(sample MetricSampleBody) domain.MetricSample {
return domain.MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
func metricSampleFromDomain(sample domain.MetricSample) MetricSampleBody {
return MetricSampleBody{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
+93 -2
View File
@@ -93,6 +93,7 @@ type AuthSessionResponse struct {
SessionID string `json:"sessionId,omitempty"` SessionID string `json:"sessionId,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
} }
type AIProviderCreateRequest struct { type AIProviderCreateRequest struct {
@@ -129,7 +130,7 @@ type AIProviderResponse struct {
Name string `json:"name"` Name string `json:"name"`
Kind domain.AIProviderKind `json:"kind"` Kind domain.AIProviderKind `json:"kind"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
APIKeyRef string `json:"apiKeyRef"` APIKeyConfigured bool `json:"apiKeyConfigured"`
Models []string `json:"models"` Models []string `json:"models"`
DefaultModel string `json:"defaultModel,omitempty"` DefaultModel string `json:"defaultModel,omitempty"`
RelayMode domain.AIRelayMode `json:"relayMode"` RelayMode domain.AIRelayMode `json:"relayMode"`
@@ -220,6 +221,7 @@ type GamePluginManifestBody struct {
Pages []GamePluginPageBody `json:"pages,omitempty"` Pages []GamePluginPageBody `json:"pages,omitempty"`
AI GamePluginManifestAIBody `json:"ai,omitempty"` AI GamePluginManifestAIBody `json:"ai,omitempty"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
} }
type GamePluginManifestRegistrationRequest struct { type GamePluginManifestRegistrationRequest struct {
@@ -246,6 +248,7 @@ type GamePluginCreateRequest struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
AIPurposes []string `json:"aiPurposes,omitempty"` AIPurposes []string `json:"aiPurposes,omitempty"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
} }
@@ -268,6 +271,7 @@ type GamePluginResponse struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"` AIPurposes []string `json:"aiPurposes"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"` Status domain.GamePluginStatus `json:"status"`
} }
@@ -296,6 +300,7 @@ type MarketplacePluginResponse struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"` AIPurposes []string `json:"aiPurposes"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"` ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"` Status domain.GamePluginStatus `json:"status"`
Source string `json:"source"` Source string `json:"source"`
@@ -398,6 +403,9 @@ type ServerInstanceResponse struct {
AdminUserIDs []string `json:"adminUserIds"` AdminUserIDs []string `json:"adminUserIds"`
State domain.ServerInstanceState `json:"state"` State domain.ServerInstanceState `json:"state"`
ConfigVersion int `json:"configVersion"` ConfigVersion int `json:"configVersion"`
ConfigKey string `json:"configKey,omitempty"`
ConfigChecksum string `json:"configChecksum,omitempty"`
ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -440,6 +448,7 @@ type ServerConfigResponse struct {
Format string `json:"format"` Format string `json:"format"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
Content string `json:"content"` Content string `json:"content"`
Checksum string `json:"checksum"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -453,6 +462,7 @@ type ConfigDiffLineResponse struct {
type ServerConfigDiffPreviewRequest struct { type ServerConfigDiffPreviewRequest struct {
ExpectedConfigVersion int `json:"expectedConfigVersion"` ExpectedConfigVersion int `json:"expectedConfigVersion"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
Key string `json:"key"` Key string `json:"key"`
ProposedContent string `json:"proposedContent,omitempty"` ProposedContent string `json:"proposedContent,omitempty"`
ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"`
@@ -461,6 +471,7 @@ type ServerConfigDiffPreviewRequest struct {
type ServerConfigDiffPreviewResponse struct { type ServerConfigDiffPreviewResponse struct {
ServerInstanceID string `json:"serverInstanceId"` ServerInstanceID string `json:"serverInstanceId"`
ConfigVersion int `json:"configVersion"` ConfigVersion int `json:"configVersion"`
Checksum string `json:"checksum"`
Key string `json:"key"` Key string `json:"key"`
CurrentContent string `json:"currentContent"` CurrentContent string `json:"currentContent"`
ProposedContent string `json:"proposedContent,omitempty"` ProposedContent string `json:"proposedContent,omitempty"`
@@ -473,6 +484,7 @@ type ServerConfigDiffPreviewResponse struct {
type ServerConfigWriteApprovalRequest struct { type ServerConfigWriteApprovalRequest struct {
ExpectedConfigVersion int `json:"expectedConfigVersion"` ExpectedConfigVersion int `json:"expectedConfigVersion"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
Key string `json:"key"` Key string `json:"key"`
ProposedContent string `json:"proposedContent,omitempty"` ProposedContent string `json:"proposedContent,omitempty"`
ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"`
@@ -491,7 +503,9 @@ type FileOperationDispatchRequest struct {
Operation domain.FileOperationKind `json:"operation"` Operation domain.FileOperationKind `json:"operation"`
Key string `json:"key"` Key string `json:"key"`
InputRef string `json:"inputRef,omitempty"` InputRef string `json:"inputRef,omitempty"`
Content string `json:"content,omitempty"`
ExpectedConfigVersion int `json:"expectedConfigVersion,omitempty"` ExpectedConfigVersion int `json:"expectedConfigVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
IdempotencyKey string `json:"idempotencyKey"` IdempotencyKey string `json:"idempotencyKey"`
} }
@@ -516,6 +530,8 @@ type RunEndpointCreateRequest struct {
ID string `json:"id"` ID string `json:"id"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Platform string `json:"platform,omitempty"`
Architecture string `json:"architecture,omitempty"`
Status domain.RunEndpointStatus `json:"status"` Status domain.RunEndpointStatus `json:"status"`
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
Capacity RunCapacityResponse `json:"capacity"` Capacity RunCapacityResponse `json:"capacity"`
@@ -526,6 +542,8 @@ type RunEndpointResponse struct {
ID string `json:"id"` ID string `json:"id"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Version string `json:"version"` Version string `json:"version"`
Platform string `json:"platform,omitempty"`
Architecture string `json:"architecture,omitempty"`
Status domain.RunEndpointStatus `json:"status"` Status domain.RunEndpointStatus `json:"status"`
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
Capacity RunCapacityResponse `json:"capacity"` Capacity RunCapacityResponse `json:"capacity"`
@@ -553,6 +571,12 @@ type JobProgressBody struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
type JobRetryPolicyResponse struct {
MaxAttempts int `json:"maxAttempts"`
InitialBackoffSeconds int `json:"initialBackoffSeconds"`
MaxBackoffSeconds int `json:"maxBackoffSeconds"`
}
type JobResponse struct { type JobResponse struct {
ID string `json:"id"` ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId,omitempty"` ServerInstanceID string `json:"serverInstanceId,omitempty"`
@@ -564,10 +588,34 @@ type JobResponse struct {
State domain.JobState `json:"state"` State domain.JobState `json:"state"`
Progress JobProgressBody `json:"progress"` Progress JobProgressBody `json:"progress"`
ResultRef string `json:"resultRef,omitempty"` 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"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type JobExecutionResultResponse struct {
Kind string `json:"kind,omitempty"`
ProcessState string `json:"processState,omitempty"`
ExitClassification string `json:"exitClassification,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Version int `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
AuditSummary string `json:"auditSummary,omitempty"`
}
type JobListResponse struct { type JobListResponse struct {
Items []JobResponse `json:"items"` Items []JobResponse `json:"items"`
Count int `json:"count"` Count int `json:"count"`
@@ -782,6 +830,7 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
Pages: pagesToDomain(request.Manifest.Pages), Pages: pagesToDomain(request.Manifest.Pages),
AI: request.Manifest.AI.ToDomain(), AI: request.Manifest.AI.ToDomain(),
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(), RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
}, },
} }
} }
@@ -843,6 +892,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
Tags: domain.CopyStringSlice(request.Tags), Tags: domain.CopyStringSlice(request.Tags),
AIPurposes: domain.CopyStringSlice(request.AIPurposes), AIPurposes: domain.CopyStringSlice(request.AIPurposes),
RemoteAccess: request.RemoteAccess.ToDomain(), RemoteAccess: request.RemoteAccess.ToDomain(),
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations), ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
} }
} }
@@ -867,6 +917,7 @@ func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string)
return domain.ServerConfigDiffRequest{ return domain.ServerConfigDiffRequest{
ServerInstanceID: serverInstanceID, ServerInstanceID: serverInstanceID,
ExpectedConfigVersion: request.ExpectedConfigVersion, ExpectedConfigVersion: request.ExpectedConfigVersion,
ExpectedChecksum: request.ExpectedChecksum,
Key: request.Key, Key: request.Key,
ProposedContent: request.ProposedContent, ProposedContent: request.ProposedContent,
ProposedContentInputRef: request.ProposedContentInputRef, ProposedContentInputRef: request.ProposedContentInputRef,
@@ -877,6 +928,7 @@ func (request ServerConfigWriteApprovalRequest) ToDomain(serverInstanceID string
return domain.ServerConfigWriteApproval{ return domain.ServerConfigWriteApproval{
ServerInstanceID: serverInstanceID, ServerInstanceID: serverInstanceID,
ExpectedConfigVersion: request.ExpectedConfigVersion, ExpectedConfigVersion: request.ExpectedConfigVersion,
ExpectedChecksum: request.ExpectedChecksum,
Key: request.Key, Key: request.Key,
ProposedContent: request.ProposedContent, ProposedContent: request.ProposedContent,
ProposedContentInputRef: request.ProposedContentInputRef, ProposedContentInputRef: request.ProposedContentInputRef,
@@ -891,7 +943,9 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa
Operation: request.Operation, Operation: request.Operation,
Key: request.Key, Key: request.Key,
InputRef: request.InputRef, InputRef: request.InputRef,
Content: request.Content,
ExpectedConfigVersion: request.ExpectedConfigVersion, ExpectedConfigVersion: request.ExpectedConfigVersion,
ExpectedChecksum: request.ExpectedChecksum,
IdempotencyKey: request.IdempotencyKey, IdempotencyKey: request.IdempotencyKey,
} }
} }
@@ -901,6 +955,8 @@ func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint {
ID: request.ID, ID: request.ID,
DisplayName: request.DisplayName, DisplayName: request.DisplayName,
Version: request.Version, Version: request.Version,
Platform: request.Platform,
Architecture: request.Architecture,
Status: request.Status, Status: request.Status,
Capabilities: domain.CopyStringSlice(request.Capabilities), Capabilities: domain.CopyStringSlice(request.Capabilities),
Capacity: capacityToDomain(request.Capacity), Capacity: capacityToDomain(request.Capacity),
@@ -988,6 +1044,7 @@ func AuthSessionFromDomain(session domain.AuthSession) AuthSessionResponse {
SessionID: session.SessionID, SessionID: session.SessionID,
Status: session.Status, Status: session.Status,
Message: session.Message, Message: session.Message,
ExpiresAt: session.ExpiresAt,
} }
} }
@@ -1020,7 +1077,7 @@ func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse {
Name: provider.Name, Name: provider.Name,
Kind: provider.Kind, Kind: provider.Kind,
BaseURL: provider.BaseURL, BaseURL: provider.BaseURL,
APIKeyRef: provider.APIKeyRef, APIKeyConfigured: provider.APIKeyRef != "",
Models: provider.Models, Models: provider.Models,
DefaultModel: provider.DefaultModel, DefaultModel: provider.DefaultModel,
RelayMode: provider.RelayMode, RelayMode: provider.RelayMode,
@@ -1079,6 +1136,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
Tags: plugin.Tags, Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes, AIPurposes: plugin.AIPurposes,
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
} }
@@ -1167,6 +1225,7 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
Tags: plugin.Tags, Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes, AIPurposes: plugin.AIPurposes,
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
Source: plugin.Source, Source: plugin.Source,
@@ -1197,6 +1256,9 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp
AdminUserIDs: adminUserIDs, AdminUserIDs: adminUserIDs,
State: instance.State, State: instance.State,
ConfigVersion: instance.ConfigVersion, ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt),
CreatedAt: instance.CreatedAt, CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt, UpdatedAt: instance.UpdatedAt,
} }
@@ -1274,6 +1336,7 @@ func ServerConfigFromDomain(config domain.ServerConfig) ServerConfigResponse {
Format: config.Format, Format: config.Format,
Key: config.Key, Key: config.Key,
Content: config.Content, Content: config.Content,
Checksum: config.Checksum,
Source: config.Source, Source: config.Source,
UpdatedAt: config.UpdatedAt, UpdatedAt: config.UpdatedAt,
} }
@@ -1293,6 +1356,7 @@ func ServerConfigDiffPreviewFromDomain(preview domain.ServerConfigDiffPreview) S
return ServerConfigDiffPreviewResponse{ return ServerConfigDiffPreviewResponse{
ServerInstanceID: preview.ServerInstanceID, ServerInstanceID: preview.ServerInstanceID,
ConfigVersion: preview.ConfigVersion, ConfigVersion: preview.ConfigVersion,
Checksum: preview.Checksum,
Key: preview.Key, Key: preview.Key,
CurrentContent: preview.CurrentContent, CurrentContent: preview.CurrentContent,
ProposedContent: preview.ProposedContent, ProposedContent: preview.ProposedContent,
@@ -1332,6 +1396,8 @@ func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
ID: endpoint.ID, ID: endpoint.ID,
DisplayName: endpoint.DisplayName, DisplayName: endpoint.DisplayName,
Version: endpoint.Version, Version: endpoint.Version,
Platform: endpoint.Platform,
Architecture: endpoint.Architecture,
Status: endpoint.Status, Status: endpoint.Status,
Capabilities: endpoint.Capabilities, Capabilities: endpoint.Capabilities,
Capacity: capacityFromDomain(endpoint.Capacity), Capacity: capacityFromDomain(endpoint.Capacity),
@@ -1359,11 +1425,36 @@ func JobFromDomain(job domain.Job) JobResponse {
State: job.State, State: job.State,
Progress: progressFromDomain(job.Progress), Progress: progressFromDomain(job.Progress),
ResultRef: job.ResultRef, 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, AuditSummary: job.ExecutionResult.AuditSummary},
RetryPolicy: JobRetryPolicyResponse{
MaxAttempts: job.RetryPolicy.MaxAttempts,
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
MaxBackoffSeconds: job.RetryPolicy.MaxBackoffSeconds,
},
Attempt: job.Attempt,
NextAttemptAt: optionalTime(job.NextAttemptAt),
AckDeadlineAt: optionalTime(job.AckDeadlineAt),
LeaseExpiresAt: optionalTime(job.LeaseExpiresAt),
CancelReason: job.CancelReason,
CancelRequestedAt: optionalTime(job.CancelRequestedAt),
CancelCompletedAt: optionalTime(job.CancelCompletedAt),
TerminalAt: optionalTime(job.TerminalAt),
LastReconciledAt: optionalTime(job.LastReconciledAt),
ReconcileCount: job.ReconcileCount,
ReconcileOutcome: job.ReconcileOutcome,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt, UpdatedAt: job.UpdatedAt,
} }
} }
func optionalTime(value time.Time) *time.Time {
if value.IsZero() {
return nil
}
copy := value
return &copy
}
func JobListFromDomain(jobs []domain.Job) JobListResponse { func JobListFromDomain(jobs []domain.Job) JobListResponse {
items := make([]JobResponse, len(jobs)) items := make([]JobResponse, len(jobs))
for i, job := range jobs { for i, job := range jobs {
+8 -5
View File
@@ -7,7 +7,7 @@ import (
"browser.local/platform/domain" "browser.local/platform/domain"
) )
func TestAIProviderResponseExposesOnlyKeyReference(t *testing.T) { func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) {
responseType := reflect.TypeOf(AIProviderResponse{}) responseType := reflect.TypeOf(AIProviderResponse{})
if _, ok := responseType.FieldByName("APIKey"); ok { if _, ok := responseType.FieldByName("APIKey"); ok {
t.Fatal("AI provider response must not expose raw API key") t.Fatal("AI provider response must not expose raw API key")
@@ -15,8 +15,11 @@ func TestAIProviderResponseExposesOnlyKeyReference(t *testing.T) {
if _, ok := responseType.FieldByName("RawAPIKey"); ok { if _, ok := responseType.FieldByName("RawAPIKey"); ok {
t.Fatal("AI provider response must not expose raw API key") t.Fatal("AI provider response must not expose raw API key")
} }
if _, ok := responseType.FieldByName("APIKeyRef"); !ok { if _, ok := responseType.FieldByName("APIKeyRef"); ok {
t.Fatal("AI provider response must expose API key reference") t.Fatal("AI provider response must not expose internal API key reference")
}
if _, ok := responseType.FieldByName("APIKeyConfigured"); !ok {
t.Fatal("AI provider response must expose API key presence")
} }
} }
@@ -41,8 +44,8 @@ func TestAIProviderFromDomainCopiesModels(t *testing.T) {
if provider.Models[0] != "gpt-4.1" { if provider.Models[0] != "gpt-4.1" {
t.Fatalf("expected response models to be copied, got source models %+v", provider.Models) t.Fatalf("expected response models to be copied, got source models %+v", provider.Models)
} }
if response.APIKeyRef != provider.APIKeyRef { if !response.APIKeyConfigured {
t.Fatalf("expected API key reference to be preserved, got %q", response.APIKeyRef) t.Fatal("expected configured API key presence")
} }
} }
+243
View File
@@ -0,0 +1,243 @@
package dto
import "browser.local/platform/domain"
type RuntimeTargetBody struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
type RuntimeDiscoveryProbeBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
Expected string `json:"expected,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeLifecycleProfileBody struct {
Key string `json:"key"`
Mode string `json:"mode"`
Capabilities []string `json:"capabilities"`
ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeDependencyProbeBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
MinimumVersion string `json:"minimumVersion,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeInstallStepBody struct {
Type string `json:"type"`
TargetKey string `json:"targetKey"`
PackageManager string `json:"packageManager,omitempty"`
PackageName string `json:"packageName,omitempty"`
Version string `json:"version,omitempty"`
DownloadRef string `json:"downloadRef,omitempty"`
Checksum string `json:"checksum,omitempty"`
}
type RuntimeInstallPlanBody struct {
Key string `json:"key"`
Title string `json:"title"`
Platforms []string `json:"platforms,omitempty"`
Steps []RuntimeInstallStepBody `json:"steps"`
}
type RuntimeLogSourceBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey,omitempty"`
StreamKey string `json:"streamKey"`
CursorKind string `json:"cursorKind,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
}
type RuntimeTransportProfileBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey,omitempty"`
Capabilities []string `json:"capabilities"`
}
type RuntimeRepositoryBody struct {
URL string `json:"url"`
RevisionPolicy string `json:"revisionPolicy"`
Branch string `json:"branch,omitempty"`
Tag string `json:"tag,omitempty"`
Revision string `json:"revision,omitempty"`
}
type RuntimeBuildBody struct {
System string `json:"system"`
WorkspaceRef string `json:"workspaceRef,omitempty"`
EntryRef string `json:"entryRef,omitempty"`
}
type RuntimeConfigTemplateBody struct {
Key string `json:"key"`
TemplateRef string `json:"templateRef"`
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 GamePluginRuntimeProfilesBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
}
func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimeProfiles {
profiles := domain.GamePluginRuntimeProfiles{}
for _, item := range body.Discovery {
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, 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)})
}
for _, item := range body.InstallPlans {
plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)}
for _, step := range item.Steps {
plan.Steps = append(plan.Steps, domain.RuntimeInstallStep{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum})
}
profiles.InstallPlans = append(profiles.InstallPlans, plan)
}
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.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)
}
return profiles
}
func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePluginRuntimeProfilesBody {
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
body := GamePluginRuntimeProfilesBody{}
for _, item := range profiles.Discovery {
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, 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})
}
for _, item := range profiles.InstallPlans {
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
for _, step := range item.Steps {
plan.Steps = append(plan.Steps, RuntimeInstallStepBody{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum})
}
body.InstallPlans = append(body.InstallPlans, plan)
}
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.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)
}
return body
}
+4
View File
@@ -9,6 +9,8 @@ type ServerLifecycleCreateRequest struct {
Name string `json:"name"` Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"` OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"` IdempotencyKey string `json:"idempotencyKey"`
ProfileKey string `json:"profileKey"`
Bindings map[string]string `json:"bindings,omitempty"`
} }
type ServerLifecycleCommandRequest struct { type ServerLifecycleCommandRequest struct {
@@ -31,6 +33,8 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre
Name: request.Name, Name: request.Name,
OwnerUserID: request.OwnerUserID, OwnerUserID: request.OwnerUserID,
IdempotencyKey: request.IdempotencyKey, IdempotencyKey: request.IdempotencyKey,
ProfileKey: request.ProfileKey,
Bindings: domain.CopyStringMap(request.Bindings),
} }
} }
+114
View File
@@ -0,0 +1,114 @@
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)
}
+16
View File
@@ -10,6 +10,7 @@ type RuntimeBinding struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
PluginID string `json:"pluginId" db:"plugin_id"` PluginID string `json:"pluginId" db:"plugin_id"`
PluginVersion string `json:"pluginVersion" db:"plugin_version"`
ProfileKey string `json:"profileKey" db:"profile_key"` ProfileKey string `json:"profileKey" db:"profile_key"`
Mode string `json:"mode" db:"mode"` Mode string `json:"mode" db:"mode"`
Bindings map[string]string `json:"bindings" db:"bindings"` Bindings map[string]string `json:"bindings" db:"bindings"`
@@ -64,6 +65,7 @@ type ClientManagerDistribution struct {
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
PluginID string `json:"pluginId" db:"plugin_id"` PluginID string `json:"pluginId" db:"plugin_id"`
ProfileKey string `json:"profileKey" db:"profile_key"` ProfileKey string `json:"profileKey" db:"profile_key"`
Version string `json:"version" db:"version"`
TargetOS string `json:"targetOs" db:"target_os"` TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"` TargetArch string `json:"targetArch" db:"target_arch"`
RepositoryURL string `json:"repositoryUrl" db:"repository_url"` RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
@@ -90,6 +92,10 @@ type DependencyStatus struct {
State domain.DependencyState `json:"state" db:"state"` State domain.DependencyState `json:"state" db:"state"`
Required bool `json:"required" db:"required"` Required bool `json:"required" db:"required"`
InstallPlanKey string `json:"installPlanKey,omitempty" db:"install_plan_key"` InstallPlanKey string `json:"installPlanKey,omitempty" db:"install_plan_key"`
PlanDigest string `json:"planDigest,omitempty" db:"plan_digest"`
JobID string `json:"jobId,omitempty" db:"job_id"`
Evidence string `json:"evidence,omitempty" db:"evidence"`
CompletedSteps int `json:"completedSteps,omitempty" db:"completed_steps"`
Message string `json:"message,omitempty" db:"message"` Message string `json:"message,omitempty" db:"message"`
CheckedAt time.Time `json:"checkedAt" db:"checked_at"` CheckedAt time.Time `json:"checkedAt" db:"checked_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
@@ -102,6 +108,7 @@ type ClientManagerBuildJob struct {
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
PluginID string `json:"pluginId" db:"plugin_id"` PluginID string `json:"pluginId" db:"plugin_id"`
ProfileKey string `json:"profileKey" db:"profile_key"` ProfileKey string `json:"profileKey" db:"profile_key"`
Version string `json:"version" db:"version"`
TargetOS string `json:"targetOs" db:"target_os"` TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"` TargetArch string `json:"targetArch" db:"target_arch"`
RepositoryURL string `json:"repositoryUrl" db:"repository_url"` RepositoryURL string `json:"repositoryUrl" db:"repository_url"`
@@ -123,9 +130,16 @@ type RunUpdateJob struct {
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"` ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"` Checksum string `json:"checksum" db:"checksum"`
TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"`
TargetRelease string `json:"targetRelease,omitempty" db:"target_release"`
PreviousVersion string `json:"previousVersion,omitempty" db:"previous_version"`
JobID string `json:"jobId" db:"job_id"` JobID string `json:"jobId" db:"job_id"`
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"` IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
Status domain.DistributionJobStatus `json:"status" db:"status"` Status domain.DistributionJobStatus `json:"status" db:"status"`
Phase domain.RunUpdatePhase `json:"phase" db:"phase"`
Message string `json:"message,omitempty" db:"message"`
Rollback bool `json:"rollback,omitempty" db:"rollback"`
CreatedAt time.Time `json:"createdAt" db:"created_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
} }
@@ -138,6 +152,7 @@ func RuntimeBindingFromDomain(binding domain.RuntimeBinding) RuntimeBinding {
ID: binding.ID, ID: binding.ID,
ServerInstanceID: binding.ServerInstanceID, ServerInstanceID: binding.ServerInstanceID,
PluginID: binding.PluginID, PluginID: binding.PluginID,
PluginVersion: binding.PluginVersion,
ProfileKey: binding.ProfileKey, ProfileKey: binding.ProfileKey,
Mode: binding.Mode, Mode: binding.Mode,
Bindings: binding.Bindings, Bindings: binding.Bindings,
@@ -153,6 +168,7 @@ func (binding RuntimeBinding) ToDomain() domain.RuntimeBinding {
ID: binding.ID, ID: binding.ID,
ServerInstanceID: binding.ServerInstanceID, ServerInstanceID: binding.ServerInstanceID,
PluginID: binding.PluginID, PluginID: binding.PluginID,
PluginVersion: binding.PluginVersion,
ProfileKey: binding.ProfileKey, ProfileKey: binding.ProfileKey,
Mode: binding.Mode, Mode: binding.Mode,
Bindings: domain.CopyStringMap(binding.Bindings), Bindings: domain.CopyStringMap(binding.Bindings),
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"time"
"browser.local/platform/domain"
)
type MetricSample struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
Online bool `json:"online" db:"online"`
PlayerCount *int `json:"playerCount,omitempty" db:"player_count"`
MaxPlayers *int `json:"maxPlayers,omitempty" db:"max_players"`
TPS *float64 `json:"tps,omitempty" db:"tps"`
LatencyMS *float64 `json:"latencyMs,omitempty" db:"latency_ms"`
CPUPercent *float64 `json:"cpuPercent,omitempty" db:"cpu_percent"`
MemoryPercent *float64 `json:"memoryPercent,omitempty" db:"memory_percent"`
DiskPercent *float64 `json:"diskPercent,omitempty" db:"disk_percent"`
Source string `json:"source" db:"source"`
CollectedAt time.Time `json:"collectedAt" db:"collected_at"`
}
func (MetricSample) TableName() string { return "metric_samples" }
func MetricSampleFromDomain(sample domain.MetricSample) MetricSample {
return MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, RunEndpointID: sample.RunEndpointID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
func (sample MetricSample) ToDomain() domain.MetricSample {
return domain.MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, RunEndpointID: sample.RunEndpointID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
type BackupRecord struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
SizeBytes int64 `json:"sizeBytes" db:"size_bytes"`
State domain.BackupState `json:"state" db:"state"`
RecoveryStatus string `json:"recoveryStatus" db:"recovery_status"`
RetentionUntil time.Time `json:"retentionUntil,omitempty" db:"retention_until"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (BackupRecord) TableName() string { return "backup_records" }
func BackupRecordFromDomain(record domain.BackupRecord) BackupRecord {
return BackupRecord{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
func (record BackupRecord) ToDomain() domain.BackupRecord {
return domain.BackupRecord{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
+205
View File
@@ -31,6 +31,44 @@ type User struct {
func (User) TableName() string { return "users" } func (User) TableName() string { return "users" }
type AuthSession struct {
// ID is a non-secret stable session record identifier.
ID string `json:"id" db:"id"`
// UserID owns the authenticated session.
UserID string `json:"userId" db:"user_id"`
// TokenHash is a one-way verifier; the bearer token is never persisted.
TokenHash string `json:"tokenHash" db:"token_hash"`
// Status tracks active or revoked lifecycle state.
Status domain.AuthSessionStatus `json:"status" db:"status"`
// Generation increments when a user rotates a session.
Generation int `json:"generation" db:"generation"`
IssuedAt time.Time `json:"issuedAt" db:"issued_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
RevokedAt time.Time `json:"revokedAt,omitempty" db:"revoked_at"`
}
func (AuthSession) TableName() string { return "auth_sessions" }
type RunControlSession struct {
// RunEndpointID is both the endpoint owner and stable session record ID.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// SessionTokenHash is a one-way verifier; raw Run tokens are never persisted.
SessionTokenHash string `json:"sessionTokenHash" db:"session_token_hash"`
Status domain.AuthSessionStatus `json:"status" db:"status"`
Generation int `json:"generation" db:"generation"`
CapabilityFingerprint string `json:"capabilityFingerprint" db:"capability_fingerprint"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds" db:"heartbeat_interval_seconds"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
RevokedAt time.Time `json:"revokedAt,omitempty" db:"revoked_at"`
RequireSignedRequests bool `json:"requireSignedRequests" db:"require_signed_requests"`
UsedNonces []string `json:"usedNonces,omitempty" db:"used_nonces"`
}
func (RunControlSession) TableName() string { return "run_control_sessions" }
type AIProvider struct { type AIProvider struct {
// ID is the stable AI provider identifier. // ID is the stable AI provider identifier.
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
@@ -145,6 +183,8 @@ type GamePlugin struct {
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"` AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
// RemoteAccess stores plugin-declared remote access metadata. // RemoteAccess stores plugin-declared remote access metadata.
RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"` RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"`
// RuntimeProfiles stores validated manifest-declared runtime contracts.
RuntimeProfiles domain.GamePluginRuntimeProfiles `json:"runtimeProfiles" db:"runtime_profiles"`
// ValidationViolations stores safe validation findings for invalid plugins. // ValidationViolations stores safe validation findings for invalid plugins.
ValidationViolations []string `json:"validationViolations" db:"validation_violations"` ValidationViolations []string `json:"validationViolations" db:"validation_violations"`
// Status is the plugin lifecycle status. // Status is the plugin lifecycle status.
@@ -172,6 +212,14 @@ type ServerInstance struct {
State domain.ServerInstanceState `json:"state" db:"state"` State domain.ServerInstanceState `json:"state" db:"state"`
// ConfigVersion is the platform-managed optimistic concurrency version. // ConfigVersion is the platform-managed optimistic concurrency version.
ConfigVersion int `json:"configVersion" db:"config_version"` ConfigVersion int `json:"configVersion" db:"config_version"`
// ConfigKey is the logical configuration target, never a host path.
ConfigKey string `json:"configKey,omitempty" db:"config_key"`
// ConfigContent is the last platform-approved bounded configuration body.
ConfigContent string `json:"configContent,omitempty" db:"config_content"`
// ConfigChecksum is the SHA-256 checksum of ConfigContent.
ConfigChecksum string `json:"configChecksum,omitempty" db:"config_checksum"`
// ConfigUpdatedAt records the last accepted config execution result.
ConfigUpdatedAt time.Time `json:"configUpdatedAt,omitempty" db:"config_updated_at"`
// CreatedAt is the record creation timestamp. // CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp. // UpdatedAt is the last update timestamp.
@@ -198,6 +246,10 @@ type RunEndpoint struct {
DisplayName string `json:"displayName" db:"display_name"` DisplayName string `json:"displayName" db:"display_name"`
// Version is the run binary version. // Version is the run binary version.
Version string `json:"version" db:"version"` Version string `json:"version" db:"version"`
// Platform is the endpoint operating system.
Platform string `json:"platform" db:"platform"`
// Architecture is the endpoint CPU architecture.
Architecture string `json:"architecture" db:"architecture"`
// Status is the current endpoint status. // Status is the current endpoint status.
Status domain.RunEndpointStatus `json:"status" db:"status"` Status domain.RunEndpointStatus `json:"status" db:"status"`
// Capabilities lists advertised run capability keys. // Capabilities lists advertised run capability keys.
@@ -217,6 +269,38 @@ type JobProgress struct {
Message string `json:"message,omitempty" db:"message"` Message string `json:"message,omitempty" db:"message"`
} }
type JobRetryPolicy struct {
// MaxAttempts bounds total claims, including the first attempt.
MaxAttempts int `json:"maxAttempts" db:"max_attempts"`
// InitialBackoffSeconds is the first retry delay.
InitialBackoffSeconds int `json:"initialBackoffSeconds" db:"initial_backoff_seconds"`
// MaxBackoffSeconds caps exponential retry delay.
MaxBackoffSeconds int `json:"maxBackoffSeconds" db:"max_backoff_seconds"`
}
type JobExecutionInput struct {
WorkspaceScope string `json:"workspaceScope,omitempty" db:"workspace_scope"`
Content string `json:"content,omitempty" db:"content"`
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"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty" db:"remote_adapter_key"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty" db:"remote_adapter_kind"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty" db:"timeout_seconds"`
}
type JobExecutionResult struct {
Kind string `json:"kind,omitempty" db:"kind"`
ProcessState string `json:"processState,omitempty" db:"process_state"`
ExitClassification string `json:"exitClassification,omitempty" db:"exit_classification"`
ExitCode int `json:"exitCode,omitempty" db:"exit_code"`
Version int `json:"version,omitempty" db:"version"`
Checksum string `json:"checksum,omitempty" db:"checksum"`
SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"`
AuditSummary string `json:"auditSummary,omitempty" db:"audit_summary"`
Content string `json:"content,omitempty" db:"content"`
}
type Job struct { type Job struct {
// ID is the stable job identifier. // ID is the stable job identifier.
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
@@ -238,6 +322,39 @@ type Job struct {
Progress JobProgress `json:"progress" db:"progress"` Progress JobProgress `json:"progress" db:"progress"`
// ResultRef references the terminal result artifact or summary. // ResultRef references the terminal result artifact or summary.
ResultRef string `json:"resultRef,omitempty" db:"result_ref"` ResultRef string `json:"resultRef,omitempty" db:"result_ref"`
// ExecutionInput is private approved input delivered only to fenced Run assignments.
ExecutionInput JobExecutionInput `json:"executionInput,omitempty" db:"execution_input"`
// ExecutionResult stores typed execution evidence; private content is not user-projected.
ExecutionResult JobExecutionResult `json:"executionResult,omitempty" db:"execution_result"`
// RetryPolicy stores bounded durable retry settings.
RetryPolicy JobRetryPolicy `json:"retryPolicy" db:"retry_policy"`
// Attempt is the current monotonic per-job attempt.
Attempt int `json:"attempt" db:"attempt"`
// QueueEligibleAt is the first time queued work may be claimed.
QueueEligibleAt time.Time `json:"queueEligibleAt,omitempty" db:"queue_eligible_at"`
// NextAttemptAt is the durable retry eligibility time.
NextAttemptAt time.Time `json:"nextAttemptAt,omitempty" db:"next_attempt_at"`
// LeaseTokenHash stores a one-way verifier, never the raw lease token.
LeaseTokenHash string `json:"leaseTokenHash,omitempty" db:"lease_token_hash"`
// LeaseSessionGen fences the attempt to an authenticated Run session generation.
LeaseSessionGen int `json:"leaseSessionGeneration,omitempty" db:"lease_session_generation"`
// AckDeadlineAt bounds how long Run has to acknowledge a claim.
AckDeadlineAt time.Time `json:"ackDeadlineAt,omitempty" db:"ack_deadline_at"`
// LeaseExpiresAt bounds execution without progress or reconciliation.
LeaseExpiresAt time.Time `json:"leaseExpiresAt,omitempty" db:"lease_expires_at"`
// LastProgressSeq rejects reordered progress updates.
LastProgressSeq uint64 `json:"lastProgressSequence,omitempty" db:"last_progress_sequence"`
// CancelReason and timestamps persist cancellation intent and result.
CancelReason string `json:"cancelReason,omitempty" db:"cancel_reason"`
CancelRequestedAt time.Time `json:"cancelRequestedAt,omitempty" db:"cancel_requested_at"`
CancelCompletedAt time.Time `json:"cancelCompletedAt,omitempty" db:"cancel_completed_at"`
// TerminalAt and TerminalFingerprint make terminal replay durable and idempotent.
TerminalAt time.Time `json:"terminalAt,omitempty" db:"terminal_at"`
TerminalFingerprint string `json:"terminalFingerprint,omitempty" db:"terminal_fingerprint"`
// Reconciliation fields provide restart recovery evidence.
LastReconciledAt time.Time `json:"lastReconciledAt,omitempty" db:"last_reconciled_at"`
ReconcileCount int `json:"reconcileCount,omitempty" db:"reconcile_count"`
ReconcileOutcome string `json:"reconcileOutcome,omitempty" db:"reconcile_outcome"`
// CreatedAt is the record creation timestamp. // CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"` CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp. // UpdatedAt is the last update timestamp.
@@ -395,6 +512,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin {
Tags: plugin.Tags, Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes, AIPurposes: plugin.AIPurposes,
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
ValidationViolations: plugin.ValidationViolations, ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status, Status: plugin.Status,
} }
@@ -419,6 +537,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin {
Tags: domain.CopyStringSlice(plugin.Tags), Tags: domain.CopyStringSlice(plugin.Tags),
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes), AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
RemoteAccess: plugin.RemoteAccess.ToDomain(), RemoteAccess: plugin.RemoteAccess.ToDomain(),
RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles),
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations), ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
Status: plugin.Status, Status: plugin.Status,
} }
@@ -526,8 +645,14 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstance {
PluginVersion: instance.PluginVersion, PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID, RunEndpointID: instance.RunEndpointID,
Name: instance.Name, Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State, State: instance.State,
ConfigVersion: instance.ConfigVersion, ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt, CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt, UpdatedAt: instance.UpdatedAt,
} }
@@ -540,8 +665,14 @@ func (instance ServerInstance) ToDomain() domain.ServerInstance {
PluginVersion: instance.PluginVersion, PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID, RunEndpointID: instance.RunEndpointID,
Name: instance.Name, Name: instance.Name,
OwnerUserID: instance.OwnerUserID,
AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs),
State: instance.State, State: instance.State,
ConfigVersion: instance.ConfigVersion, ConfigVersion: instance.ConfigVersion,
ConfigKey: instance.ConfigKey,
ConfigContent: instance.ConfigContent,
ConfigChecksum: instance.ConfigChecksum,
ConfigUpdatedAt: instance.ConfigUpdatedAt,
CreatedAt: instance.CreatedAt, CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt, UpdatedAt: instance.UpdatedAt,
} }
@@ -553,6 +684,8 @@ func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpoint {
ID: endpoint.ID, ID: endpoint.ID,
DisplayName: endpoint.DisplayName, DisplayName: endpoint.DisplayName,
Version: endpoint.Version, Version: endpoint.Version,
Platform: endpoint.Platform,
Architecture: endpoint.Architecture,
Status: endpoint.Status, Status: endpoint.Status,
Capabilities: endpoint.Capabilities, Capabilities: endpoint.Capabilities,
Capacity: capacityFromDomain(endpoint.Capacity), Capacity: capacityFromDomain(endpoint.Capacity),
@@ -565,6 +698,8 @@ func (endpoint RunEndpoint) ToDomain() domain.RunEndpoint {
ID: endpoint.ID, ID: endpoint.ID,
DisplayName: endpoint.DisplayName, DisplayName: endpoint.DisplayName,
Version: endpoint.Version, Version: endpoint.Version,
Platform: endpoint.Platform,
Architecture: endpoint.Architecture,
Status: endpoint.Status, Status: endpoint.Status,
Capabilities: domain.CopyStringSlice(endpoint.Capabilities), Capabilities: domain.CopyStringSlice(endpoint.Capabilities),
Capacity: endpoint.Capacity.ToDomain(), Capacity: endpoint.Capacity.ToDomain(),
@@ -602,6 +737,25 @@ func JobFromDomain(job domain.Job) Job {
State: job.State, State: job.State,
Progress: progressFromDomain(job.Progress), Progress: progressFromDomain(job.Progress),
ResultRef: job.ResultRef, ResultRef: job.ResultRef,
ExecutionInput: executionInputFromDomain(job.ExecutionInput),
ExecutionResult: executionResultFromDomain(job.ExecutionResult),
RetryPolicy: retryPolicyFromDomain(job.RetryPolicy),
Attempt: job.Attempt,
QueueEligibleAt: job.QueueEligibleAt,
NextAttemptAt: job.NextAttemptAt,
LeaseTokenHash: job.LeaseTokenHash,
LeaseSessionGen: job.LeaseSessionGen,
AckDeadlineAt: job.AckDeadlineAt,
LeaseExpiresAt: job.LeaseExpiresAt,
LastProgressSeq: job.LastProgressSeq,
CancelReason: job.CancelReason,
CancelRequestedAt: job.CancelRequestedAt,
CancelCompletedAt: job.CancelCompletedAt,
TerminalAt: job.TerminalAt,
TerminalFingerprint: job.TerminalFingerprint,
LastReconciledAt: job.LastReconciledAt,
ReconcileCount: job.ReconcileCount,
ReconcileOutcome: job.ReconcileOutcome,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt, UpdatedAt: job.UpdatedAt,
} }
@@ -619,11 +773,62 @@ func (job Job) ToDomain() domain.Job {
State: job.State, State: job.State,
Progress: job.Progress.ToDomain(), Progress: job.Progress.ToDomain(),
ResultRef: job.ResultRef, ResultRef: job.ResultRef,
ExecutionInput: job.ExecutionInput.ToDomain(),
ExecutionResult: job.ExecutionResult.ToDomain(),
RetryPolicy: job.RetryPolicy.ToDomain(),
Attempt: job.Attempt,
QueueEligibleAt: job.QueueEligibleAt,
NextAttemptAt: job.NextAttemptAt,
LeaseTokenHash: job.LeaseTokenHash,
LeaseSessionGen: job.LeaseSessionGen,
AckDeadlineAt: job.AckDeadlineAt,
LeaseExpiresAt: job.LeaseExpiresAt,
LastProgressSeq: job.LastProgressSeq,
CancelReason: job.CancelReason,
CancelRequestedAt: job.CancelRequestedAt,
CancelCompletedAt: job.CancelCompletedAt,
TerminalAt: job.TerminalAt,
TerminalFingerprint: job.TerminalFingerprint,
LastReconciledAt: job.LastReconciledAt,
ReconcileCount: job.ReconcileCount,
ReconcileOutcome: job.ReconcileOutcome,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt, UpdatedAt: job.UpdatedAt,
} }
} }
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
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}
}
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
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}
}
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content}
}
func (result JobExecutionResult) ToDomain() domain.JobExecutionResult {
return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content}
}
func (policy JobRetryPolicy) ToDomain() domain.JobRetryPolicy {
return domain.JobRetryPolicy{
MaxAttempts: policy.MaxAttempts,
InitialBackoffSeconds: policy.InitialBackoffSeconds,
MaxBackoffSeconds: policy.MaxBackoffSeconds,
}
}
func retryPolicyFromDomain(policy domain.JobRetryPolicy) JobRetryPolicy {
return JobRetryPolicy{
MaxAttempts: policy.MaxAttempts,
InitialBackoffSeconds: policy.InitialBackoffSeconds,
MaxBackoffSeconds: policy.MaxBackoffSeconds,
}
}
func (progress JobProgress) ToDomain() domain.JobProgress { func (progress JobProgress) ToDomain() domain.JobProgress {
return domain.JobProgress{ return domain.JobProgress{
Percent: progress.Percent, Percent: progress.Percent,
+8
View File
@@ -17,6 +17,9 @@ func TestTableNames(t *testing.T) {
Artifact{}.TableName(): "artifacts", Artifact{}.TableName(): "artifacts",
LogStream{}.TableName(): "log_streams", LogStream{}.TableName(): "log_streams",
AuditEvent{}.TableName(): "audit_events", AuditEvent{}.TableName(): "audit_events",
ClientManagerInstallation{}.TableName(): "client_manager_installations",
ClientManagerSession{}.TableName(): "client_manager_sessions",
ClientManagerRegistrationNonce{}.TableName(): "client_manager_registration_nonces",
} }
for got, want := range tests { for got, want := range tests {
@@ -42,6 +45,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
}, },
Tags: []string{"survival"}, Tags: []string{"survival"},
AIPurposes: []string{"logs.diagnose"}, AIPurposes: []string{"logs.diagnose"},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}},
Permissions: domain.PluginPermissions{ Permissions: domain.PluginPermissions{
Jobs: true, Jobs: true,
Logs: true, Logs: true,
@@ -57,6 +61,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
roundTrip.Pages[0].Permissions[0] = "ai.invoke" roundTrip.Pages[0].Permissions[0] = "ai.invoke"
roundTrip.Tags[0] = "mutated" roundTrip.Tags[0] = "mutated"
roundTrip.AIPurposes[0] = "config.suggest" roundTrip.AIPurposes[0] = "config.suggest"
roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop"
if source.RequiredRunCapabilities[0] != "process.start" { if source.RequiredRunCapabilities[0] != "process.start" {
t.Fatalf("expected source plugin capabilities to remain unchanged, got %+v", source.RequiredRunCapabilities) t.Fatalf("expected source plugin capabilities to remain unchanged, got %+v", source.RequiredRunCapabilities)
@@ -70,6 +75,9 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" { if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" {
t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row) t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row)
} }
if source.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" || row.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" {
t.Fatalf("expected runtime profiles to round-trip without aliasing, source=%+v row=%+v", source.RuntimeProfiles, row.RuntimeProfiles)
}
} }
func TestAIProviderModelUsesKeyReference(t *testing.T) { func TestAIProviderModelUsesKeyReference(t *testing.T) {
+1 -1
View File
@@ -32,7 +32,7 @@ AI invocation responses must be bounded and must not include raw provider creden
- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material. - `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material.
- `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer. - `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer.
- `AIProviderStatusRequest`: set provider status to `active` or `disabled`. - `AIProviderStatusRequest`: set provider status to `active` or `disabled`.
- `AIProviderResponse`: redacted provider response with `apiKeyRef` only. - `AIProviderResponse`: redacted provider response with `apiKeyConfigured` only; it does not expose the stored secret reference.
- `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred. - `AIProviderTestResponse`: local metadata validation result with `mode=metadata`; live external connectivity is deferred.
- `AIProviderModelsResponse`: configured model list and default model, without credentials. - `AIProviderModelsResponse`: configured model list and default model, without credentials.
+30
View File
@@ -0,0 +1,30 @@
# Authentication and Service Identity Contracts
## Platform bearer sessions
- Login and first-user registration issue a random bearer token with an eight-hour expiry.
- Strict production HTTP routes deliver the session through an `HttpOnly`, `SameSite=Strict` cookie and omit it from JSON. `X-Auth-Token-Response: bearer` is an explicit CLI compatibility mode.
- Durable stores keep only `AuthSessionRecord.tokenHash`, owner, generation, status, and lifecycle timestamps.
- Logout and rotation set `status=revoked` and `revokedAt`; rotation issues a distinct generation.
- Missing, unknown, expired, revoked, or disabled-user sessions return a safe `401 unauthorized` error.
## Authorization roles
- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, audit, and global internal resource creation.
- server owner: server membership, runtime binding changes, destructive/archive operations, and all visible server actions.
- server administrator: non-owner operational access to assigned server resources, but no owner-only membership or secret/key rotation.
- Run service: control/job/log/artifact channels for its current endpoint session; it cannot use browser bearer authority.
Job, log, artifact, runtime-binding, distribution, and plugin-bridge services resolve the target server and repeat ownership checks independently from the HTTP router.
## Run signed envelope
Component-authenticated Run hello responses advertise `signed-envelope.v1.required`. Subsequent HTTP channel calls carry `X-Run-Endpoint`, `X-Run-Timestamp` (Unix seconds), `X-Run-Nonce`, and `X-Run-Signature` (hex HMAC-SHA256).
The canonical payload is `METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + SHA256(BODY)`. The current Run session token is the HMAC key. The platform rejects endpoint mismatch, invalid signatures, timestamps outside a five-minute window, expired/revoked sessions, and replayed nonces. Legacy non-component local test sessions remain an explicit compatibility path and advertise the envelope as optional.
## Secret boundary
Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and controlled `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only.
Component-key ciphertext uses an injectable AES-GCM envelope derived from `PLATFORM_SECRET_ENVELOPE_KEY`; the built-in key is a disposable-development fallback only. This boundary is not a production KMS/vault. External key wrapping, KMS/HSM integration, multi-node replay coordination, envelope-key migration, and secret-value rotation remain deferred risks.
@@ -0,0 +1,19 @@
# Dependency And Run Update Contracts
Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and audit projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies.
## Dependency flow
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 approval is denied and audited.
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`.
## Self-update flow
1. Platform accepts only an available Run distribution owned by the same server and matching the registered endpoint OS/architecture/checksum.
2. Run retrieves private metadata through `update-input`, reads 1 MiB-or-smaller ranges through `update-chunk`, persists offsets, verifies the final artifact checksum, rejects traversal/links/devices/unexpected entries, and stages exactly the expected executable without replacing configuration.
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, production scaling/alerts, external mirrors/storage, or real AI-provider integration.
@@ -0,0 +1,58 @@
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)
}
}
+106
View File
@@ -14,14 +14,28 @@ import (
type StoreSnapshot struct { type StoreSnapshot struct {
Users []domain.User `json:"users"` Users []domain.User `json:"users"`
AuthSessions []domain.AuthSessionRecord `json:"authSessions"`
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
AIProviders []domain.AIProvider `json:"aiProviders"` AIProviders []domain.AIProvider `json:"aiProviders"`
GamePlugins []domain.GamePlugin `json:"gamePlugins"` GamePlugins []domain.GamePlugin `json:"gamePlugins"`
ServerInstances []domain.ServerInstance `json:"serverInstances"` ServerInstances []domain.ServerInstance `json:"serverInstances"`
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
Jobs []domain.Job `json:"jobs"` Jobs []domain.Job `json:"jobs"`
Artifacts []domain.Artifact `json:"artifacts"` 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"` LogStreams []domain.LogStream `json:"logStreams"`
AuditEvents []domain.AuditEvent `json:"auditEvents"` AuditEvents []domain.AuditEvent `json:"auditEvents"`
MetricSamples []domain.MetricSample `json:"metricSamples"`
Backups []domain.BackupRecord `json:"backups"`
} }
type FileStore struct { type FileStore struct {
@@ -53,6 +67,14 @@ func (store *FileStore) Users() UserRepository {
return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist} return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist}
} }
func (store *FileStore) AuthSessions() AuthSessionRepository {
return &persistentRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]{repository: store.MemoryStore.authSessions, persist: store.persist}
}
func (store *FileStore) RunControlSessions() RunControlSessionRepository {
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persist}
}
func (store *FileStore) AIProviders() AIProviderRepository { func (store *FileStore) AIProviders() AIProviderRepository {
return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist} return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist}
} }
@@ -80,6 +102,46 @@ func (store *FileStore) Artifacts() ArtifactRepository {
return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist} return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist}
} }
func (store *FileStore) RuntimeBindings() RuntimeBindingRepository {
return &persistentRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]{repository: store.MemoryStore.runtimeBindings, persist: store.persist}
}
func (store *FileStore) EncryptedComponentKeys() EncryptedComponentKeyRepository {
return &persistentRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]{repository: store.MemoryStore.componentKeys, persist: store.persist}
}
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 { 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.persist}
} }
@@ -88,6 +150,14 @@ func (store *FileStore) AuditEvents() AuditEventRepository {
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
} }
func (store *FileStore) MetricSamples() MetricSampleRepository {
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
}
func (store *FileStore) Backups() BackupRepository {
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
}
func (store *FileStore) load() error { func (store *FileStore) load() error {
data, err := os.ReadFile(store.path) data, err := os.ReadFile(store.path)
if err != nil { if err != nil {
@@ -132,27 +202,55 @@ func (store *FileStore) persist() error {
func (store *FileStore) snapshot() StoreSnapshot { func (store *FileStore) snapshot() StoreSnapshot {
return StoreSnapshot{ return StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users), Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
AIProviders: snapshotRepository(store.MemoryStore.aiProviders), AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
Artifacts: snapshotRepository(store.MemoryStore.artifacts), 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), LogStreams: snapshotRepository(store.MemoryStore.logStreams),
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
Backups: snapshotRepository(store.MemoryStore.backups),
} }
} }
func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.users, snapshot.Users) loadRepository(store.MemoryStore.users, snapshot.Users)
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders) loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders)
loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins) loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins)
loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances)
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs)
loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts) loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts)
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.logStreams, snapshot.LogStreams)
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
loadRepository(store.MemoryStore.backups, snapshot.Backups)
} }
type mutableRepository[T any, F any] interface { type mutableRepository[T any, F any] interface {
@@ -160,6 +258,7 @@ type mutableRepository[T any, F any] interface {
Get(string) (T, error) Get(string) (T, error)
List(F) ([]T, error) List(F) ([]T, error)
Update(T) error Update(T) error
Delete(string) error
} }
type persistentRepository[T any, F any] struct { type persistentRepository[T any, F any] struct {
@@ -189,6 +288,13 @@ func (repository *persistentRepository[T, F]) Update(value T) error {
return repository.persist() return repository.persist()
} }
func (repository *persistentRepository[T, F]) Delete(id string) error {
if err := repository.repository.Delete(id); err != nil {
return err
}
return repository.persist()
}
type persistentJobRepository struct { type persistentJobRepository struct {
*persistentRepository[domain.Job, domain.JobFilter] *persistentRepository[domain.Job, domain.JobFilter]
repository JobRepository repository JobRepository
+84
View File
@@ -54,6 +54,14 @@ func (store *MySQLStore) Users() UserRepository {
return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist} return &persistentRepository[domain.User, domain.UserFilter]{repository: store.MemoryStore.users, persist: store.persist}
} }
func (store *MySQLStore) AuthSessions() AuthSessionRepository {
return &persistentRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]{repository: store.MemoryStore.authSessions, persist: store.persist}
}
func (store *MySQLStore) RunControlSessions() RunControlSessionRepository {
return &persistentRepository[domain.RunControlSession, struct{}]{repository: store.MemoryStore.runSessions, persist: store.persist}
}
func (store *MySQLStore) AIProviders() AIProviderRepository { func (store *MySQLStore) AIProviders() AIProviderRepository {
return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist} return &persistentRepository[domain.AIProvider, domain.AIProviderFilter]{repository: store.MemoryStore.aiProviders, persist: store.persist}
} }
@@ -81,6 +89,46 @@ func (store *MySQLStore) Artifacts() ArtifactRepository {
return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist} return &persistentRepository[domain.Artifact, domain.ArtifactFilter]{repository: store.MemoryStore.artifacts, persist: store.persist}
} }
func (store *MySQLStore) RuntimeBindings() RuntimeBindingRepository {
return &persistentRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]{repository: store.MemoryStore.runtimeBindings, persist: store.persist}
}
func (store *MySQLStore) EncryptedComponentKeys() EncryptedComponentKeyRepository {
return &persistentRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]{repository: store.MemoryStore.componentKeys, persist: store.persist}
}
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 { 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.persist}
} }
@@ -89,6 +137,14 @@ func (store *MySQLStore) AuditEvents() AuditEventRepository {
return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist}
} }
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist}
}
func (store *MySQLStore) Backups() BackupRepository {
return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist}
}
func (store *MySQLStore) initialize() error { func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -150,25 +206,53 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq
func (store *MySQLStore) snapshot() StoreSnapshot { func (store *MySQLStore) snapshot() StoreSnapshot {
return StoreSnapshot{ return StoreSnapshot{
Users: snapshotRepository(store.MemoryStore.users), Users: snapshotRepository(store.MemoryStore.users),
AuthSessions: snapshotRepository(store.MemoryStore.authSessions),
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
AIProviders: snapshotRepository(store.MemoryStore.aiProviders), AIProviders: snapshotRepository(store.MemoryStore.aiProviders),
GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins),
ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), ServerInstances: snapshotRepository(store.MemoryStore.serverInstances),
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository),
Artifacts: snapshotRepository(store.MemoryStore.artifacts), 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), LogStreams: snapshotRepository(store.MemoryStore.logStreams),
AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), AuditEvents: snapshotRepository(store.MemoryStore.auditEvents),
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
Backups: snapshotRepository(store.MemoryStore.backups),
} }
} }
func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.users, snapshot.Users) loadRepository(store.MemoryStore.users, snapshot.Users)
loadRepository(store.MemoryStore.authSessions, snapshot.AuthSessions)
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders) loadRepository(store.MemoryStore.aiProviders, snapshot.AIProviders)
loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins) loadRepository(store.MemoryStore.gamePlugins, snapshot.GamePlugins)
loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances)
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs)
loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts) loadRepository(store.MemoryStore.artifacts, snapshot.Artifacts)
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.logStreams, snapshot.LogStreams)
loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents)
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
loadRepository(store.MemoryStore.backups, snapshot.Backups)
} }
+160
View File
@@ -20,6 +20,20 @@ type UserRepository interface {
Update(domain.User) error Update(domain.User) error
} }
type AuthSessionRepository interface {
Create(domain.AuthSessionRecord) error
Get(id string) (domain.AuthSessionRecord, error)
List(domain.AuthSessionFilter) ([]domain.AuthSessionRecord, error)
Update(domain.AuthSessionRecord) error
}
type RunControlSessionRepository interface {
Create(domain.RunControlSession) error
Get(id string) (domain.RunControlSession, error)
List(struct{}) ([]domain.RunControlSession, error)
Update(domain.RunControlSession) error
}
type AIProviderRepository interface { type AIProviderRepository interface {
Create(domain.AIProvider) error Create(domain.AIProvider) error
Get(id string) (domain.AIProvider, error) Get(id string) (domain.AIProvider, error)
@@ -91,6 +105,28 @@ type ClientManagerDistributionRepository interface {
Update(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 { type DependencyStatusRepository interface {
Create(domain.DependencyStatus) error Create(domain.DependencyStatus) error
Get(id string) (domain.DependencyStatus, error) Get(id string) (domain.DependencyStatus, error)
@@ -126,8 +162,26 @@ type AuditEventRepository interface {
Update(domain.AuditEvent) error Update(domain.AuditEvent) error
} }
type MetricSampleRepository interface {
Create(domain.MetricSample) error
Get(id string) (domain.MetricSample, error)
List(domain.MetricSampleFilter) ([]domain.MetricSample, error)
Update(domain.MetricSample) error
Delete(id string) error
}
type BackupRepository interface {
Create(domain.BackupRecord) error
Get(id string) (domain.BackupRecord, error)
List(domain.BackupFilter) ([]domain.BackupRecord, error)
Update(domain.BackupRecord) error
Delete(id string) error
}
type Store interface { type Store interface {
Users() UserRepository Users() UserRepository
AuthSessions() AuthSessionRepository
RunControlSessions() RunControlSessionRepository
AIProviders() AIProviderRepository AIProviders() AIProviderRepository
GamePlugins() GamePluginRepository GamePlugins() GamePluginRepository
ServerInstances() ServerInstanceRepository ServerInstances() ServerInstanceRepository
@@ -138,15 +192,22 @@ type Store interface {
EncryptedComponentKeys() EncryptedComponentKeyRepository EncryptedComponentKeys() EncryptedComponentKeyRepository
RunDistributions() RunDistributionRepository RunDistributions() RunDistributionRepository
ClientManagerDistributions() ClientManagerDistributionRepository ClientManagerDistributions() ClientManagerDistributionRepository
ClientManagerInstallations() ClientManagerInstallationRepository
ClientManagerSessions() ClientManagerSessionRepository
ClientManagerNonces() ClientManagerNonceRepository
DependencyStatuses() DependencyStatusRepository DependencyStatuses() DependencyStatusRepository
ClientManagerBuildJobs() ClientManagerBuildJobRepository ClientManagerBuildJobs() ClientManagerBuildJobRepository
RunUpdateJobs() RunUpdateJobRepository RunUpdateJobs() RunUpdateJobRepository
LogStreams() LogStreamRepository LogStreams() LogStreamRepository
AuditEvents() AuditEventRepository AuditEvents() AuditEventRepository
MetricSamples() MetricSampleRepository
Backups() BackupRepository
} }
type MemoryStore struct { type MemoryStore struct {
users *memoryRepository[domain.User, domain.UserFilter] users *memoryRepository[domain.User, domain.UserFilter]
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
runSessions *memoryRepository[domain.RunControlSession, struct{}]
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
@@ -157,11 +218,16 @@ type MemoryStore struct {
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] 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] dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
} }
func NewMemoryStore() *MemoryStore { func NewMemoryStore() *MemoryStore {
@@ -171,6 +237,16 @@ func NewMemoryStore() *MemoryStore {
domain.CopyUser, domain.CopyUser,
matchUser, matchUser,
), ),
authSessions: newMemoryRepository(
func(session domain.AuthSessionRecord) string { return session.ID },
domain.CopyAuthSessionRecord,
matchAuthSession,
),
runSessions: newMemoryRepository(
func(session domain.RunControlSession) string { return session.RunEndpointID },
domain.CopyRunControlSession,
func(domain.RunControlSession, struct{}) bool { return true },
),
aiProviders: newMemoryRepository( aiProviders: newMemoryRepository(
func(provider domain.AIProvider) string { return provider.ID }, func(provider domain.AIProvider) string { return provider.ID },
domain.CopyAIProvider, domain.CopyAIProvider,
@@ -217,6 +293,21 @@ func NewMemoryStore() *MemoryStore {
domain.CopyClientManagerDistribution, domain.CopyClientManagerDistribution,
matchClientManagerDistribution, 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( dependencies: newMemoryRepository(
func(status domain.DependencyStatus) string { return status.ID }, func(status domain.DependencyStatus) string { return status.ID },
domain.CopyDependencyStatus, domain.CopyDependencyStatus,
@@ -242,10 +333,22 @@ func NewMemoryStore() *MemoryStore {
domain.CopyAuditEvent, domain.CopyAuditEvent,
matchAuditEvent, matchAuditEvent,
), ),
metricSamples: newMemoryRepository(
func(sample domain.MetricSample) string { return sample.ID },
domain.CopyMetricSample,
matchMetricSample,
),
backups: newMemoryRepository(
func(record domain.BackupRecord) string { return record.ID },
domain.CopyBackupRecord,
matchBackup,
),
} }
} }
func (store *MemoryStore) Users() UserRepository { return store.users } func (store *MemoryStore) Users() UserRepository { return store.users }
func (store *MemoryStore) AuthSessions() AuthSessionRepository { return store.authSessions }
func (store *MemoryStore) RunControlSessions() RunControlSessionRepository { return store.runSessions }
func (store *MemoryStore) AIProviders() AIProviderRepository { return store.aiProviders } func (store *MemoryStore) AIProviders() AIProviderRepository { return store.aiProviders }
func (store *MemoryStore) GamePlugins() GamePluginRepository { return store.gamePlugins } func (store *MemoryStore) GamePlugins() GamePluginRepository { return store.gamePlugins }
func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances } func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances }
@@ -260,6 +363,15 @@ func (store *MemoryStore) RunDistributions() RunDistributionRepository { return
func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository { func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository {
return store.clientDists 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) DependencyStatuses() DependencyStatusRepository { return store.dependencies } func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies }
func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository {
return store.buildJobs return store.buildJobs
@@ -267,6 +379,8 @@ func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobReposito
func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs }
func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams }
func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents } func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents }
func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples }
func (store *MemoryStore) Backups() BackupRepository { return store.backups }
type memoryRepository[T any, F any] struct { type memoryRepository[T any, F any] struct {
mu sync.RWMutex mu sync.RWMutex
@@ -341,6 +455,16 @@ func (repository *memoryRepository[T, F]) Update(value T) error {
return nil return nil
} }
func (repository *memoryRepository[T, F]) Delete(id string) error {
repository.mu.Lock()
defer repository.mu.Unlock()
if _, exists := repository.byID[id]; !exists {
return ErrNotFound
}
delete(repository.byID, id)
return nil
}
type memoryJobRepository struct { type memoryJobRepository struct {
*memoryRepository[domain.Job, domain.JobFilter] *memoryRepository[domain.Job, domain.JobFilter]
} }
@@ -371,6 +495,12 @@ func matchUser(user domain.User, filter domain.UserFilter) bool {
return filter.Status == "" || user.Status == filter.Status return filter.Status == "" || user.Status == filter.Status
} }
func matchAuthSession(session domain.AuthSessionRecord, filter domain.AuthSessionFilter) bool {
return (filter.UserID == "" || session.UserID == filter.UserID) &&
(filter.TokenHash == "" || session.TokenHash == filter.TokenHash) &&
(filter.Status == "" || session.Status == filter.Status)
}
func matchAIProvider(provider domain.AIProvider, filter domain.AIProviderFilter) bool { func matchAIProvider(provider domain.AIProvider, filter domain.AIProviderFilter) bool {
return (filter.Kind == "" || provider.Kind == filter.Kind) && return (filter.Kind == "" || provider.Kind == filter.Kind) &&
(filter.Status == "" || provider.Status == filter.Status) (filter.Status == "" || provider.Status == filter.Status)
@@ -444,6 +574,25 @@ func matchClientManagerDistribution(distribution domain.ClientManagerDistributio
(filter.Status == "" || distribution.Status == filter.Status) (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 { func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool {
return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) && return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) &&
(filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) && (filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) &&
@@ -472,3 +621,14 @@ func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bo
(filter.ResourceID == "" || event.ResourceID == filter.ResourceID) && (filter.ResourceID == "" || event.ResourceID == filter.ResourceID) &&
(filter.Result == "" || event.Result == filter.Result) (filter.Result == "" || event.Result == filter.Result)
} }
func matchMetricSample(sample domain.MetricSample, filter domain.MetricSampleFilter) bool {
return (filter.ServerInstanceID == "" || sample.ServerInstanceID == filter.ServerInstanceID) &&
(filter.After.IsZero() || sample.CollectedAt.After(filter.After)) &&
(filter.Before.IsZero() || !sample.CollectedAt.After(filter.Before))
}
func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool {
return (filter.ServerInstanceID == "" || record.ServerInstanceID == filter.ServerInstanceID) &&
(filter.State == "" || record.State == filter.State)
}
+127 -2
View File
@@ -1,10 +1,13 @@
package repo package repo
import ( import (
"encoding/json"
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"browser.local/platform/domain" "browser.local/platform/domain"
) )
@@ -75,7 +78,6 @@ func TestMemoryJobRepositoryFindsIdempotencyKey(t *testing.T) {
if err := store.Jobs().Create(job); err != nil { if err := store.Jobs().Create(job); err != nil {
t.Fatalf("create job: %v", err) t.Fatalf("create job: %v", err)
} }
got, err := store.Jobs().GetByIdempotency("run-local", "idem-1") got, err := store.Jobs().GetByIdempotency("run-local", "idem-1")
if err != nil { if err != nil {
t.Fatalf("get by idempotency: %v", err) t.Fatalf("get by idempotency: %v", err)
@@ -105,6 +107,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
if err := store.Users().Create(user); err != nil { if err := store.Users().Create(user); err != nil {
t.Fatalf("create user: %v", err) t.Fatalf("create user: %v", err)
} }
stamp := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
job := domain.Job{ job := domain.Job{
ID: "job-1", ID: "job-1",
RunEndpointID: "run-local", RunEndpointID: "run-local",
@@ -112,10 +115,69 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
Capability: "process.start", Capability: "process.start",
IdempotencyKey: "idem-1", IdempotencyKey: "idem-1",
State: domain.JobStateQueued, State: domain.JobStateQueued,
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 4, InitialBackoffSeconds: 3, MaxBackoffSeconds: 30},
Attempt: 2,
QueueEligibleAt: stamp,
NextAttemptAt: stamp.Add(3 * time.Second),
LeaseTokenHash: strings.Repeat("d", 64),
LeaseSessionGen: 2,
AckDeadlineAt: stamp.Add(15 * time.Second),
LeaseExpiresAt: stamp.Add(time.Minute),
LastProgressSeq: 7,
CancelReason: "operator requested",
CancelRequestedAt: stamp,
LastReconciledAt: stamp,
ReconcileCount: 2,
ReconcileOutcome: "confirmed active attempt",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "name=approved\n", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("1", 64), MaxReadBytes: 64 * 1024},
ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"},
} }
if err := store.Jobs().Create(job); err != nil { if err := store.Jobs().Create(job); err != nil {
t.Fatalf("create job: %v", err) t.Fatalf("create job: %v", err)
} }
plugin := domain.GamePlugin{ID: "game.runtime", Name: "Runtime", Version: "1.0.0", RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
}
binding := domain.RuntimeBinding{ID: "runtime-binding-server-1", ServerInstanceID: "server-1", PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Bindings: map[string]string{"rcon.password": "secret://server-1/rcon"}, Status: domain.RuntimeBindingStatusComplete, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("create runtime binding: %v", err)
}
runEndpoint := domain.RunEndpoint{ID: "run-target", DisplayName: "Target Run", Version: "release-1", Platform: "linux", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 2}, LastHeartbeatAt: stamp}
if err := store.RunEndpoints().Create(runEndpoint); err != nil {
t.Fatalf("create target Run endpoint: %v", err)
}
planDigest := "sha256:" + strings.Repeat("e", 64)
dependencyStatus := domain.DependencyStatus{ID: "dependency-server-1-java", ServerInstanceID: "server-1", PluginID: plugin.ID, ProbeKey: "java", TargetOS: "linux", TargetArch: "amd64", State: domain.DependencyStatePresent, Required: true, InstallPlanKey: "java-install", PlanDigest: planDigest, JobID: "job-dependency", Evidence: "OpenJDK 21", CompletedSteps: 1, Message: "dependency execution completed", CheckedAt: stamp, UpdatedAt: stamp}
if err := store.DependencyStatuses().Create(dependencyStatus); err != nil {
t.Fatalf("create dependency status: %v", err)
}
runUpdate := domain.RunUpdateJob{ID: "run-update-1", ServerInstanceID: "server-1", RunEndpointID: runEndpoint.ID, ArtifactID: "artifact-run-2", Checksum: planDigest, TargetOS: "linux", TargetArch: "amd64", TargetRelease: "release-2", PreviousVersion: "release-1", JobID: "job-update", IdempotencyKey: "run-update-idempotent", Status: domain.DistributionJobStatusFailed, Phase: domain.RunUpdatePhaseRolledBack, Message: "previous executable restored", Rollback: true, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.RunUpdateJobs().Create(runUpdate); err != nil {
t.Fatalf("create Run update status: %v", err)
}
authSession := domain.AuthSessionRecord{ID: "auth-session-1", UserID: user.ID, TokenHash: strings.Repeat("a", 64), Status: domain.AuthSessionStatusActive, Generation: 1, IssuedAt: stamp, ExpiresAt: stamp.Add(time.Hour), LastSeenAt: stamp}
if err := store.AuthSessions().Create(authSession); err != nil {
t.Fatalf("create auth session: %v", err)
}
runSession := domain.RunControlSession{RunEndpointID: "run-local", SessionToken: "raw-run-token", SessionTokenHash: strings.Repeat("b", 64), Status: domain.AuthSessionStatusActive, Generation: 2, CapabilityFingerprint: "cap-v2", HeartbeatIntervalSeconds: 15, CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true, UsedNonces: []string{"nonce-1"}}
if err := store.RunControlSessions().Create(runSession); err != nil {
t.Fatalf("create run session: %v", err)
}
componentKey := domain.EncryptedComponentKey{ID: "component-key-1", ServerInstanceID: "server-1", ComponentKind: domain.DistributionComponentRun, EncryptedKey: "ciphertext-only", KeyHash: strings.Repeat("c", 64), Fingerprint: "fingerprint", SecretRef: "secret://components/server-1/run", Generation: 1, Status: domain.ComponentKeyStatusActive, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.EncryptedComponentKeys().Create(componentKey); err != nil {
t.Fatalf("create component key: %v", err)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), "raw-run-token") {
t.Fatalf("snapshot exposed raw Run token: %s", payload)
}
if strings.Contains(string(payload), "raw-job-lease") {
t.Fatalf("snapshot exposed raw job lease: %s", payload)
}
reloaded, err := NewFileStore(path) reloaded, err := NewFileStore(path)
if err != nil { if err != nil {
@@ -132,9 +194,72 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get reloaded job by idempotency: %v", err) t.Fatalf("get reloaded job by idempotency: %v", err)
} }
if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" { if gotJob.ID != "job-1" || gotJob.ServerInstanceID != "server-1" || gotJob.Attempt != 2 || gotJob.RetryPolicy.MaxAttempts != 4 || gotJob.LeaseTokenHash != strings.Repeat("d", 64) || gotJob.ReconcileCount != 2 || gotJob.ExecutionInput.Content != "name=approved\n" || gotJob.ExecutionResult.Content != "private-read" {
t.Fatalf("unexpected reloaded job: %+v", gotJob) t.Fatalf("unexpected reloaded job: %+v", gotJob)
} }
gotPlugin, err := reloaded.GamePlugins().Get(plugin.ID)
if err != nil || len(gotPlugin.RuntimeProfiles.LifecycleProfiles) != 1 || gotPlugin.RuntimeProfiles.LifecycleProfiles[0].Key != "local" {
t.Fatalf("unexpected reloaded runtime profiles: plugin=%+v err=%v", gotPlugin, err)
}
gotBindings, err := reloaded.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: "server-1"})
if err != nil || len(gotBindings) != 1 || gotBindings[0].Bindings["rcon.password"] != "secret://server-1/rcon" {
t.Fatalf("unexpected reloaded runtime binding: bindings=%+v err=%v", gotBindings, err)
}
gotEndpoint, err := reloaded.RunEndpoints().Get(runEndpoint.ID)
if err != nil || gotEndpoint.Platform != "linux" || gotEndpoint.Architecture != "amd64" || gotEndpoint.Version != "release-1" {
t.Fatalf("unexpected reloaded Run target: endpoint=%+v err=%v", gotEndpoint, err)
}
gotDependencies, err := reloaded.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: "server-1"})
if err != nil || len(gotDependencies) != 1 || gotDependencies[0].PlanDigest != planDigest || gotDependencies[0].Evidence != "OpenJDK 21" {
t.Fatalf("unexpected reloaded dependency status: statuses=%+v err=%v", gotDependencies, err)
}
gotUpdates, err := reloaded.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: "server-1"})
if err != nil || len(gotUpdates) != 1 || gotUpdates[0].Phase != domain.RunUpdatePhaseRolledBack || !gotUpdates[0].Rollback || gotUpdates[0].TargetRelease != "release-2" {
t.Fatalf("unexpected reloaded Run update: updates=%+v err=%v", gotUpdates, err)
}
gotAuth, err := reloaded.AuthSessions().Get(authSession.ID)
if err != nil || gotAuth.TokenHash != authSession.TokenHash || gotAuth.Generation != 1 {
t.Fatalf("unexpected reloaded auth session: session=%+v err=%v", gotAuth, err)
}
gotRun, err := reloaded.RunControlSessions().Get(runSession.RunEndpointID)
if err != nil || gotRun.SessionToken != "" || gotRun.SessionTokenHash != runSession.SessionTokenHash || len(gotRun.UsedNonces) != 1 {
t.Fatalf("unexpected reloaded Run session: session=%+v err=%v", gotRun, err)
}
gotKey, err := reloaded.EncryptedComponentKeys().Get(componentKey.ID)
if err != nil || gotKey.EncryptedKey != componentKey.EncryptedKey || gotKey.SecretRef != componentKey.SecretRef {
t.Fatalf("unexpected reloaded component key metadata: key=%+v err=%v", gotKey, err)
}
}
func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) {
stamp := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
source := &MySQLStore{MemoryStore: NewMemoryStore()}
job := domain.Job{
ID: "job-mysql", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-mysql",
State: domain.JobStateRunning, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 3, InitialBackoffSeconds: 2, MaxBackoffSeconds: 60},
Attempt: 2, QueueEligibleAt: stamp, LeaseTokenHash: strings.Repeat("e", 64), LeaseSessionGen: 4,
LeaseExpiresAt: stamp.Add(time.Minute), LastProgressSeq: 8, CancelReason: "stop", CancelRequestedAt: stamp,
LastReconciledAt: stamp, ReconcileCount: 3, ReconcileOutcome: "confirmed active attempt", CreatedAt: stamp, UpdatedAt: stamp,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "local", Content: "mysql-approved", ExpectedVersion: 1, ExpectedChecksum: "sha256:" + strings.Repeat("3", 64), MaxReadBytes: 64 * 1024},
ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"},
}
if err := source.MemoryStore.Jobs().Create(job); err != nil {
t.Fatalf("create source job: %v", err)
}
payload, err := json.Marshal(source.snapshot())
if err != nil {
t.Fatalf("marshal mysql snapshot: %v", err)
}
var snapshot StoreSnapshot
if err := json.Unmarshal(payload, &snapshot); err != nil {
t.Fatalf("unmarshal mysql snapshot: %v", err)
}
target := &MySQLStore{MemoryStore: NewMemoryStore()}
target.loadSnapshot(snapshot)
got, err := target.MemoryStore.Jobs().Get(job.ID)
if err != nil || got.Attempt != job.Attempt || got.LeaseTokenHash != job.LeaseTokenHash || got.LastProgressSeq != job.LastProgressSeq || got.ReconcileCount != job.ReconcileCount || got.ExecutionInput.Content != job.ExecutionInput.Content || got.ExecutionResult.Checksum != job.ExecutionResult.Checksum {
t.Fatalf("unexpected MySQL snapshot job: job=%+v err=%v", got, err)
}
} }
func TestMySQLStoreRequiresDSN(t *testing.T) { func TestMySQLStoreRequiresDSN(t *testing.T) {
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
type ArtifactBodyStore interface {
SaveTransfer(domain.ArtifactTransferSession) error
LoadTransfers() ([]domain.ArtifactTransferSession, error)
PutPayload(string, []byte) error
GetPayload(string) ([]byte, error)
}
type MemoryArtifactBodyStore struct {
mu sync.Mutex
transfers map[string]domain.ArtifactTransferSession
payloads map[string][]byte
}
func NewMemoryArtifactBodyStore() *MemoryArtifactBodyStore {
return &MemoryArtifactBodyStore{transfers: map[string]domain.ArtifactTransferSession{}, payloads: map[string][]byte{}}
}
func (store *MemoryArtifactBodyStore) SaveTransfer(session domain.ArtifactTransferSession) error {
store.mu.Lock()
defer store.mu.Unlock()
store.transfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return nil
}
func (store *MemoryArtifactBodyStore) LoadTransfers() ([]domain.ArtifactTransferSession, error) {
store.mu.Lock()
defer store.mu.Unlock()
ids := make([]string, 0, len(store.transfers))
for id := range store.transfers {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]domain.ArtifactTransferSession, 0, len(ids))
for _, id := range ids {
out = append(out, domain.CopyArtifactTransferSession(store.transfers[id]))
}
return out, nil
}
func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []byte) error {
store.mu.Lock()
defer store.mu.Unlock()
store.payloads[artifactID] = domain.CopyBytes(payload)
return nil
}
func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
store.mu.Lock()
defer store.mu.Unlock()
payload, exists := store.payloads[artifactID]
if !exists {
return nil, repo.ErrNotFound
}
return domain.CopyBytes(payload), nil
}
type FileArtifactBodyStore struct {
mu sync.Mutex
rootDir string
}
func NewFileArtifactBodyStore(rootDir string) (*FileArtifactBodyStore, error) {
rootDir = strings.TrimSpace(rootDir)
if rootDir == "" {
return nil, fmt.Errorf("artifact directory is required")
}
for _, path := range []string{rootDir, filepath.Join(rootDir, "transfers"), filepath.Join(rootDir, "payloads")} {
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, fmt.Errorf("create artifact body directory: %w", err)
}
}
return &FileArtifactBodyStore{rootDir: rootDir}, nil
}
func (store *FileArtifactBodyStore) SaveTransfer(session domain.ArtifactTransferSession) error {
store.mu.Lock()
defer store.mu.Unlock()
dir := store.transferDir(session.TransferID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create artifact transfer directory: %w", err)
}
manifest := domain.CopyArtifactTransferSession(session)
for index, record := range manifest.ReceivedChunks {
payload := domain.CopyBytes(record.Payload)
if len(payload) != record.SizeBytes || validator.BytesChecksum(payload) != record.Checksum {
return validationError("artifact chunk does not match durable manifest")
}
if err := writeAtomicFile(filepath.Join(dir, fmt.Sprintf("chunk-%08d.bin", index)), payload, 0o600); err != nil {
return err
}
record.Payload = nil
manifest.ReceivedChunks[index] = record
}
body, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encode artifact transfer manifest: %w", err)
}
return writeAtomicFile(filepath.Join(dir, "manifest.json"), body, 0o600)
}
func (store *FileArtifactBodyStore) LoadTransfers() ([]domain.ArtifactTransferSession, error) {
store.mu.Lock()
defer store.mu.Unlock()
entries, err := os.ReadDir(filepath.Join(store.rootDir, "transfers"))
if err != nil {
return nil, fmt.Errorf("read artifact transfer directory: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
out := make([]domain.ArtifactTransferSession, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(store.rootDir, "transfers", entry.Name())
body, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("read artifact transfer manifest: %w", err)
}
var session domain.ArtifactTransferSession
if err := json.Unmarshal(body, &session); err != nil {
return nil, fmt.Errorf("decode artifact transfer manifest: %w", err)
}
if session.TransferID == "" || store.transferDir(session.TransferID) != dir {
return nil, fmt.Errorf("artifact transfer manifest identity mismatch")
}
for index, record := range session.ReceivedChunks {
payload, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("chunk-%08d.bin", index)))
if err != nil {
return nil, fmt.Errorf("read artifact transfer chunk: %w", err)
}
if len(payload) != record.SizeBytes || validator.BytesChecksum(payload) != record.Checksum {
return nil, validationError("durable artifact chunk checksum mismatch")
}
record.Payload = payload
session.ReceivedChunks[index] = record
}
out = append(out, domain.CopyArtifactTransferSession(session))
}
return out, nil
}
func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte) error {
store.mu.Lock()
defer store.mu.Unlock()
return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600)
}
func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
store.mu.Lock()
defer store.mu.Unlock()
payload, err := os.ReadFile(store.payloadPath(artifactID))
if errors.Is(err, os.ErrNotExist) {
return nil, repo.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("read artifact payload: %w", err)
}
return payload, nil
}
func (store *FileArtifactBodyStore) transferDir(transferID string) string {
return filepath.Join(store.rootDir, "transfers", stableStorageKey(transferID))
}
func (store *FileArtifactBodyStore) payloadPath(artifactID string) string {
return filepath.Join(store.rootDir, "payloads", stableStorageKey(artifactID)+".bin")
}
func stableStorageKey(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
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)
}
tmp := path + ".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.Write(payload); err != nil {
_ = file.Close()
_ = os.Remove(tmp)
return fmt.Errorf("write 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, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace durable body: %w", err)
}
return nil
}
+13 -1
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"net/url" "net/url"
"sort" "sort"
@@ -8,10 +9,11 @@ import (
"time" "time"
"browser.local/platform/domain" "browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator" "browser.local/platform/validator"
) )
const artifactDownloadStorageBehavior = "platform-memory-transfer-session" const artifactDownloadStorageBehavior = "platform-durable-artifact-store"
func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) { func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) {
artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID)) artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID))
@@ -160,6 +162,12 @@ func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
if payload, exists := svc.artifactPayloads[artifactID]; exists { if payload, exists := svc.artifactPayloads[artifactID]; exists {
return domain.CopyBytes(payload), nil return domain.CopyBytes(payload), nil
} }
if payload, err := svc.artifactStore.GetPayload(artifactID); err == nil {
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return payload, nil
} else if !errors.Is(err, repo.ErrNotFound) {
return nil, err
}
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers)) sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
for _, session := range svc.artifactTransfers { for _, session := range svc.artifactTransfers {
@@ -183,6 +191,10 @@ func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
if int64(len(payload)) != session.SizeBytes { if int64(len(payload)) != session.SizeBytes {
return nil, validationError("artifact content size does not match transfer") return nil, validationError("artifact content size does not match transfer")
} }
if err := svc.artifactStore.PutPayload(artifactID, payload); err != nil {
return nil, err
}
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return payload, nil return payload, nil
} }
+13
View File
@@ -82,6 +82,9 @@ func (svc *CoreService) OpenArtifactTransfer(open domain.ArtifactTransferOpen) (
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: stamp, UpdatedAt: stamp,
} }
if err := svc.artifactStore.SaveTransfer(session); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return artifactTransferOpenResult(session, artifact, false, stamp), nil return artifactTransferOpenResult(session, artifact, false, stamp), nil
} }
@@ -123,6 +126,9 @@ func (svc *CoreService) UploadArtifactChunk(chunk domain.ArtifactChunkUpload) (d
ReceivedAt: stamp, ReceivedAt: stamp,
} }
session.UpdatedAt = stamp session.UpdatedAt = stamp
if err := svc.artifactStore.SaveTransfer(session); err != nil {
return domain.ArtifactChunkUploadResult{}, err
}
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return artifactChunkUploadResult(session, chunk.ChunkIndex, false, stamp), nil return artifactChunkUploadResult(session, chunk.ChunkIndex, false, stamp), nil
} }
@@ -201,11 +207,18 @@ func (svc *CoreService) CompleteArtifactTransfer(complete domain.ArtifactTransfe
if err := validator.ValidateArtifact(artifact); err != nil { if err := validator.ValidateArtifact(artifact); err != nil {
return domain.ArtifactTransferCompleteResult{}, err return domain.ArtifactTransferCompleteResult{}, err
} }
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
if err := svc.store.Artifacts().Update(artifact); err != nil { if err := svc.store.Artifacts().Update(artifact); err != nil {
return domain.ArtifactTransferCompleteResult{}, err return domain.ArtifactTransferCompleteResult{}, err
} }
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
session.Completed = true session.Completed = true
session.UpdatedAt = stamp session.UpdatedAt = stamp
if err := svc.artifactStore.SaveTransfer(session); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil
} }
@@ -1,6 +1,7 @@
package service package service
import ( import (
"bytes"
"strings" "strings"
"testing" "testing"
@@ -76,6 +77,11 @@ func TestCoreServiceArtifactTransferWorkflow(t *testing.T) {
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) { if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) {
t.Fatalf("expected available artifact, got %+v", artifact) t.Fatalf("expected available artifact, got %+v", artifact)
} }
delete(svc.artifactTransfers, opened.TransferID)
storedPayload, err := svc.artifactPayload("artifact-1")
if err != nil || !bytes.Equal(storedPayload, payload) {
t.Fatalf("expected completed upload payload to remain downloadable, payload=%q err=%v", storedPayload, err)
}
} }
func TestCoreServiceRejectsInvalidArtifactTransferChunks(t *testing.T) { func TestCoreServiceRejectsInvalidArtifactTransferChunks(t *testing.T) {
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const defaultAuthSessionTTL = 8 * time.Hour
func (svc *CoreService) issueAuthSession(user domain.User, message string) (domain.AuthSession, error) {
token, err := randomToken()
if err != nil {
return domain.AuthSession{}, err
}
hash := tokenHash(token)
stamp := svc.now()
generation := 1
existing, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{UserID: user.ID})
if err != nil {
return domain.AuthSession{}, err
}
for _, session := range existing {
if session.Generation >= generation {
generation = session.Generation + 1
}
}
record := domain.AuthSessionRecord{
ID: "auth-session-" + hash[:24],
UserID: user.ID,
TokenHash: hash,
Status: domain.AuthSessionStatusActive,
Generation: generation,
IssuedAt: stamp,
ExpiresAt: stamp.Add(defaultAuthSessionTTL),
LastSeenAt: stamp,
}
if err := validator.ValidateAuthSessionRecord(record); err != nil {
return domain.AuthSession{}, err
}
if err := svc.store.AuthSessions().Create(record); err != nil {
return domain.AuthSession{}, err
}
svc.authMu.Lock()
svc.authSessions[token] = user.ID
svc.authMu.Unlock()
return domain.AuthSession{
SessionID: token,
User: domain.CopyUser(user),
Status: "authenticated",
Message: message,
ExpiresAt: record.ExpiresAt,
}, nil
}
func (svc *CoreService) authenticatedSession(token string) (domain.AuthSessionRecord, error) {
token = strings.TrimSpace(token)
if token == "" {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
hash := tokenHash(token)
sessions, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{TokenHash: hash})
if err != nil {
return domain.AuthSessionRecord{}, err
}
if len(sessions) != 1 || subtle.ConstantTimeCompare([]byte(sessions[0].TokenHash), []byte(hash)) != 1 {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
session := sessions[0]
stamp := svc.now()
if session.Status != domain.AuthSessionStatusActive || !session.RevokedAt.IsZero() || !stamp.Before(session.ExpiresAt) {
if session.Status == domain.AuthSessionStatusActive && !stamp.Before(session.ExpiresAt) {
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
_ = svc.store.AuthSessions().Update(session)
}
return domain.AuthSessionRecord{}, ErrUnauthorized
}
user, err := svc.store.Users().Get(session.UserID)
if err != nil {
if errors.Is(err, repo.ErrNotFound) {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
return domain.AuthSessionRecord{}, err
}
if user.Status != domain.UserStatusActive {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
if session.LastSeenAt.IsZero() || stamp.Sub(session.LastSeenAt) >= time.Minute {
session.LastSeenAt = stamp
if err := svc.store.AuthSessions().Update(session); err != nil {
return domain.AuthSessionRecord{}, err
}
}
return domain.CopyAuthSessionRecord(session), nil
}
func (svc *CoreService) revokeAuthSession(token string) error {
session, err := svc.authenticatedSession(token)
if err != nil {
return err
}
stamp := svc.now()
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
session.LastSeenAt = stamp
if err := validator.ValidateAuthSessionRecord(session); err != nil {
return err
}
if err := svc.store.AuthSessions().Update(session); err != nil {
return err
}
svc.authMu.Lock()
delete(svc.authSessions, token)
svc.authMu.Unlock()
return nil
}
func (svc *CoreService) RotateUserSession(token string) (domain.AuthSession, error) {
session, err := svc.authenticatedSession(token)
if err != nil {
return domain.AuthSession{}, err
}
user, err := svc.store.Users().Get(session.UserID)
if err != nil {
return domain.AuthSession{}, err
}
if err := svc.revokeAuthSession(token); err != nil {
return domain.AuthSession{}, err
}
return svc.issueAuthSession(user, "会话已安全轮换")
}
func (svc *CoreService) revokeUserSessions(userID string) error {
sessions, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{UserID: userID, Status: domain.AuthSessionStatusActive})
if err != nil {
return err
}
stamp := svc.now()
for _, session := range sessions {
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
session.LastSeenAt = stamp
if err := validator.ValidateAuthSessionRecord(session); err != nil {
return err
}
if err := svc.store.AuthSessions().Update(session); err != nil {
return err
}
}
return nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+194
View File
@@ -0,0 +1,194 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestAuthSessionPersistsWithoutRawTokenAndRotates(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
user, err := svc.CreateUser(domain.User{
ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test",
Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
if session.SessionID == "" || !session.ExpiresAt.Equal(now.Add(defaultAuthSessionTTL)) {
t.Fatalf("unexpected bounded session: %+v", session)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), session.SessionID) || strings.Contains(string(payload), "secret-password") {
t.Fatalf("snapshot contains raw session or password literal: %s", payload)
}
if !strings.Contains(string(payload), tokenHash(session.SessionID)) {
t.Fatalf("snapshot does not contain the expected one-way session verifier")
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
if current, err := reloaded.GetCurrentUser(session.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("restored session was not accepted: current=%+v err=%v", current, err)
}
rotated, err := reloaded.RotateUserSession(session.SessionID)
if err != nil {
t.Fatalf("rotate session: %v", err)
}
if rotated.SessionID == "" || rotated.SessionID == session.SessionID {
t.Fatalf("rotation did not issue a distinct token")
}
if _, err := reloaded.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("revoked prior token should be unauthorized, got %v", err)
}
if current, err := reloaded.GetCurrentUser(rotated.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("rotated token was not accepted: current=%+v err=%v", current, err)
}
}
func TestAuthSessionExpiryIsDurablyRevoked(t *testing.T) {
store := repo.NewMemoryStore()
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
if _, err := svc.CreateUser(domain.User{ID: "user-expiry", DisplayName: "Expiry", Email: "expiry@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: "expiry@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
now = now.Add(defaultAuthSessionTTL + time.Second)
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expired session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{TokenHash: tokenHash(session.SessionID)})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("expired session was not durably revoked: records=%+v err=%v", records, err)
}
}
func TestDisablingUserRevokesActiveSessions(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
user, err := svc.CreateUser(domain.User{ID: "user-disabled", DisplayName: "Disabled", Email: "disabled@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
user.Status = domain.UserStatusDisabled
if _, err := svc.UpdateUser(user.ID, user); err != nil {
t.Fatalf("disable user: %v", err)
}
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("disabled user session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{UserID: user.ID})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("disabled user sessions were not revoked: records=%+v err=%v", records, err)
}
}
func TestRunSessionPersistsAndSignedEnvelopeRejectsReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register run: %v", err)
}
record, err := store.RunControlSessions().Get("run-local")
if err != nil {
t.Fatalf("get run session: %v", err)
}
record.RequireSignedRequests = true
if err := store.RunControlSessions().Update(record); err != nil {
t.Fatalf("require signed requests: %v", err)
}
delete(svc.runSessions, "run-local")
request := domain.RunRequestSignature{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Method: "POST",
Path: "/api/v1/run/jobs/claim",
Timestamp: strconv.FormatInt(now.Unix(), 10),
Nonce: "nonce-1",
BodyHash: strings.Repeat("a", 64),
}
request.Signature = signRunRequest(request)
if err := svc.AuthorizeRunRequestSignature(request); err != nil {
t.Fatalf("authorize signed request: %v", err)
}
if err := svc.AuthorizeRunRequestSignature(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("replayed nonce should be unauthorized, got %v", err)
}
stale := request
stale.Nonce = "nonce-2"
stale.Timestamp = strconv.FormatInt(now.Add(-maxRunRequestClockSkew-time.Second).Unix(), 10)
stale.Signature = signRunRequest(stale)
if err := svc.AuthorizeRunRequestSignature(stale); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("stale signature should be unauthorized, got %v", err)
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
result, err := reloaded.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1",
Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs",
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil || !result.Accepted {
t.Fatalf("reloaded hashed Run session was not accepted: result=%+v err=%v", result, err)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), hello.SessionToken) || strings.Contains(string(payload), "registration-token") {
t.Fatalf("snapshot contains raw Run credential: %s", payload)
}
}
func signRunRequest(request domain.RunRequestSignature) string {
canonical := strings.Join([]string{request.Method, request.Path, request.Timestamp, request.Nonce, request.BodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(request.SessionToken))
_, _ = mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
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()
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)
}
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, []byte("client-manager-package-"+version))
}
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, AuditSummary: "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"}
}
+152 -10
View File
@@ -1,8 +1,14 @@
package service package service
import ( import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -12,6 +18,9 @@ import (
const ( const (
defaultHeartbeatIntervalSeconds = 15 defaultHeartbeatIntervalSeconds = 15
defaultRunSessionTTL = 24 * time.Hour
maxRunRequestClockSkew = 5 * time.Minute
maxRunRequestNonces = 8192
) )
func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.RunControlHelloResult, error) { func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.RunControlHelloResult, error) {
@@ -46,6 +55,8 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
ID: hello.RunEndpointID, ID: hello.RunEndpointID,
DisplayName: hello.DisplayName, DisplayName: hello.DisplayName,
Version: hello.Version, Version: hello.Version,
Platform: hello.Platform,
Architecture: hello.Architecture,
Status: domain.RunEndpointStatusOnline, Status: domain.RunEndpointStatusOnline,
Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities), Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities),
Capacity: hello.Capacity, Capacity: hello.Capacity,
@@ -61,23 +72,53 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
if err := svc.upsertRunEndpoint(endpoint); err != nil { if err := svc.upsertRunEndpoint(endpoint); err != nil {
return domain.RunControlHelloResult{}, err return domain.RunControlHelloResult{}, err
} }
sessionToken := svc.nextSessionToken(hello.RunEndpointID, stamp) previous, previousErr := svc.store.RunControlSessions().Get(hello.RunEndpointID)
svc.runSessions[hello.RunEndpointID] = domain.RunControlSession{ generation := 1
if previousErr == nil {
generation = previous.Generation + 1
} else if !errors.Is(previousErr, repo.ErrNotFound) {
return domain.RunControlHelloResult{}, previousErr
}
sessionToken, err := svc.nextSessionToken()
if err != nil {
return domain.RunControlHelloResult{}, err
}
session := domain.RunControlSession{
RunEndpointID: hello.RunEndpointID, RunEndpointID: hello.RunEndpointID,
SessionToken: sessionToken, SessionToken: sessionToken,
SessionTokenHash: tokenHash(sessionToken),
Status: domain.AuthSessionStatusActive,
Generation: generation,
CapabilityFingerprint: hello.CapabilityReport.Fingerprint, CapabilityFingerprint: hello.CapabilityReport.Fingerprint,
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: stamp, UpdatedAt: stamp,
ExpiresAt: stamp.Add(defaultRunSessionTTL),
RequireSignedRequests: hasComponentAuthIdentity(hello),
}
if err := validator.ValidateRunControlSession(session); err != nil {
return domain.RunControlHelloResult{}, err
}
if previousErr == nil {
if err := svc.store.RunControlSessions().Update(session); err != nil {
return domain.RunControlHelloResult{}, err
}
} else if err := svc.store.RunControlSessions().Create(session); err != nil {
return domain.RunControlHelloResult{}, err
}
svc.runSessions[hello.RunEndpointID] = session
featureFlags := []string{"control.hello", "control.heartbeat", "signed-envelope.v1.optional"}
if session.RequireSignedRequests {
featureFlags[2] = "signed-envelope.v1.required"
} }
return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{ return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{
Accepted: true, Accepted: true,
RunEndpointID: hello.RunEndpointID, RunEndpointID: hello.RunEndpointID,
SessionToken: sessionToken, SessionToken: sessionToken,
ServerTime: stamp, ServerTime: stamp,
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
FeatureFlags: []string{"control.hello", "control.heartbeat"}, SessionExpiresAt: session.ExpiresAt,
FeatureFlags: featureFlags,
}), nil }), nil
} }
@@ -96,9 +137,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat)
svc.controlMu.Lock() svc.controlMu.Lock()
defer svc.controlMu.Unlock() defer svc.controlMu.Unlock()
session, exists := svc.runSessions[heartbeat.RunEndpointID] session, err := svc.currentRunSession(heartbeat.RunEndpointID, heartbeat.SessionToken)
if !exists || session.SessionToken != heartbeat.SessionToken { if err != nil {
return domain.RunControlHeartbeatResult{}, validationError("sessionToken is invalid") return domain.RunControlHeartbeatResult{}, err
} }
endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID) endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID)
@@ -119,6 +160,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat)
refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint
session.CapabilityFingerprint = heartbeat.CapabilityFingerprint session.CapabilityFingerprint = heartbeat.CapabilityFingerprint
session.UpdatedAt = stamp session.UpdatedAt = stamp
if err := svc.store.RunControlSessions().Update(session); err != nil {
return domain.RunControlHeartbeatResult{}, err
}
svc.runSessions[heartbeat.RunEndpointID] = session svc.runSessions[heartbeat.RunEndpointID] = session
return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{ return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{
@@ -140,7 +184,105 @@ func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error {
return svc.store.RunEndpoints().Update(endpoint) return svc.store.RunEndpoints().Update(endpoint)
} }
func (svc *CoreService) nextSessionToken(runEndpointID string, stamp time.Time) string { func (svc *CoreService) nextSessionToken() (string, error) {
svc.runSessionSeq++ return randomToken()
return fmt.Sprintf("session:%s:%d:%d", runEndpointID, stamp.UnixNano(), svc.runSessionSeq) }
func (svc *CoreService) currentRunSession(runEndpointID string, sessionToken string) (domain.RunControlSession, error) {
session, exists := svc.runSessions[runEndpointID]
if !exists {
stored, err := svc.store.RunControlSessions().Get(runEndpointID)
if err != nil {
return domain.RunControlSession{}, runAuthenticationError(true)
}
session = stored
}
presentedHash := tokenHash(strings.TrimSpace(sessionToken))
if strings.TrimSpace(sessionToken) == "" || session.Status != domain.AuthSessionStatusActive || !session.RevokedAt.IsZero() || !svc.now().Before(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(session.SessionTokenHash), []byte(presentedHash)) != 1 {
if session.Status == domain.AuthSessionStatusActive && !svc.now().Before(session.ExpiresAt) {
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = svc.now()
session.UpdatedAt = session.RevokedAt
_ = svc.store.RunControlSessions().Update(session)
}
return domain.RunControlSession{}, runAuthenticationError(session.RequireSignedRequests)
}
return domain.CopyRunControlSession(session), nil
}
func runAuthenticationError(requireSigned bool) error {
if !requireSigned {
return validationError("sessionToken is invalid")
}
return fmt.Errorf("sessionToken is invalid: %w", ErrUnauthorized)
}
func (svc *CoreService) AuthorizeRunRequestSignature(request domain.RunRequestSignature) error {
svc.controlMu.Lock()
defer svc.controlMu.Unlock()
session, err := svc.currentRunSession(request.RunEndpointID, request.SessionToken)
if err != nil {
return err
}
if strings.TrimSpace(request.Signature) == "" && !session.RequireSignedRequests {
return nil
}
if strings.TrimSpace(request.Timestamp) == "" || strings.TrimSpace(request.Nonce) == "" || strings.TrimSpace(request.Signature) == "" {
return runAuthenticationError(true)
}
unixSeconds, err := strconv.ParseInt(request.Timestamp, 10, 64)
if err != nil {
return runAuthenticationError(true)
}
stamp := time.Unix(unixSeconds, 0).UTC()
delta := svc.now().Sub(stamp)
if delta < -maxRunRequestClockSkew || delta > maxRunRequestClockSkew {
return runAuthenticationError(true)
}
session.UsedNonces = activeRunNonces(session.UsedNonces, svc.now().Add(-maxRunRequestClockSkew))
if len(request.Nonce) > 128 || runNonceSeen(session.UsedNonces, request.Nonce) || len(session.UsedNonces) >= maxRunRequestNonces {
return runAuthenticationError(true)
}
canonical := strings.Join([]string{request.Method, request.Path, request.Timestamp, request.Nonce, request.BodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(request.SessionToken))
_, _ = mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
provided, err := hex.DecodeString(request.Signature)
if err != nil || subtle.ConstantTimeCompare([]byte(expected), []byte(hex.EncodeToString(provided))) != 1 {
return runAuthenticationError(true)
}
session.UsedNonces = append(session.UsedNonces, request.Timestamp+":"+request.Nonce)
session.UpdatedAt = svc.now()
if err := svc.store.RunControlSessions().Update(session); err != nil {
return err
}
svc.runSessions[request.RunEndpointID] = session
return nil
}
func activeRunNonces(entries []string, cutoff time.Time) []string {
active := make([]string, 0, len(entries))
for _, entry := range entries {
timestamp, _, ok := strings.Cut(entry, ":")
if !ok {
active = append(active, entry)
continue
}
unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil && !time.Unix(unixSeconds, 0).Before(cutoff) {
active = append(active, entry)
}
}
return active
}
func runNonceSeen(entries []string, nonce string) bool {
for _, entry := range entries {
_, storedNonce, ok := strings.Cut(entry, ":")
if (ok && storedNonce == nonce) || (!ok && entry == nonce) {
return true
}
}
return false
} }
+615
View File
@@ -0,0 +1,615 @@
package service
import (
"encoding/json"
"errors"
"net/url"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const runUpdateChunkSize = 1024 * 1024
type dependencyResolution struct {
instance domain.ServerInstance
plugin domain.GamePlugin
binding domain.RuntimeBinding
endpoint domain.RunEndpoint
}
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.DependencyCatalog{}, err
}
resolution, err := svc.resolveDependencyContext(serverInstanceID)
if err != nil {
return domain.DependencyCatalog{}, err
}
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.DependencyCatalog{}, err
}
statusByProbe := map[string]domain.DependencyStatus{}
for _, status := range statuses {
statusByProbe[status.ProbeKey] = status
}
plans := make([]domain.DependencyPlanView, 0, len(resolution.plugin.RuntimeProfiles.InstallPlans))
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
if !runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) {
continue
}
steps := make([]domain.DependencyPlanStepView, len(plan.Steps))
for i, step := range plan.Steps {
host := ""
if parsed, parseErr := url.Parse(step.DownloadRef); parseErr == nil {
host = parsed.Hostname()
}
steps[i] = domain.DependencyPlanStepView{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadHost: host}
}
var planProbe domain.RuntimeDependencyProbe
for _, candidate := range resolution.plugin.RuntimeProfiles.DependencyProbes {
if runtimePlatformsContain(candidate.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, candidate) {
planProbe = candidate
break
}
}
plans = append(plans, domain.DependencyPlanView{Key: plan.Key, Title: plan.Title, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Digest: dependencyPlanDigest(resolution, planProbe, plan), Steps: steps})
}
sort.Slice(plans, func(i, j int) bool { return plans[i].Key < plans[j].Key })
probes := make([]domain.DependencyProbeView, 0, len(resolution.plugin.RuntimeProfiles.DependencyProbes))
for _, probe := range resolution.plugin.RuntimeProfiles.DependencyProbes {
if !runtimePlatformsContain(probe.Platforms, resolution.endpoint.Platform) {
continue
}
status := statusByProbe[probe.Key]
planKey := ""
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
if runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, probe) {
planKey = plan.Key
break
}
}
state := status.State
if state == "" {
state = domain.DependencyStateUnknown
}
probes = append(probes, domain.DependencyProbeView{Key: probe.Key, Kind: probe.Kind, Required: probe.Required, MinimumVersion: probe.MinimumVersion, State: state, Evidence: status.Evidence, InstallPlanKey: planKey})
}
sort.Slice(probes, func(i, j int) bool { return probes[i].Key < probes[j].Key })
return domain.CopyDependencyCatalog(domain.DependencyCatalog{ServerInstanceID: resolution.instance.ID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Probes: probes, Plans: plans, UpdatedAt: svc.now()}), nil
}
func (svc *CoreService) ListRunUpdateJobsForSession(sessionID, serverInstanceID string) ([]domain.RunUpdateJob, error) {
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return nil, err
}
items, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return nil, err
}
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
for i := range items {
items[i] = domain.CopyRunUpdateJob(items[i])
}
return items, nil
}
func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) {
if err := validator.ValidateDependencyExecutionInputRequest(request); err != nil {
return domain.DependencyExecutionInput{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if job.Capability != domain.JobCapabilityDependenciesCheck && job.Capability != domain.JobCapabilityDependenciesInstall {
return domain.DependencyExecutionInput{}, validationError("job is not a dependency operation")
}
resolution, err := svc.resolveDependencyContext(job.ServerInstanceID)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if resolution.endpoint.ID != job.RunEndpointID {
return domain.DependencyExecutionInput{}, validationError("dependency endpoint no longer matches")
}
probeKey := strings.TrimPrefix(job.TargetKey, "dependencies/")
if job.Capability == domain.JobCapabilityDependenciesInstall {
probeKey = ""
}
var probe domain.RuntimeDependencyProbe
if probeKey != "" {
probe, err = declaredDependencyProbe(resolution.plugin, probeKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
}
var plan domain.RuntimeInstallPlan
if job.Capability == domain.JobCapabilityDependenciesInstall {
planKey := strings.TrimPrefix(job.TargetKey, "dependencies/install/")
plan, err = declaredInstallPlan(resolution.plugin, planKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
statuses, listErr := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: job.ServerInstanceID})
if listErr != nil {
return domain.DependencyExecutionInput{}, listErr
}
for _, status := range statuses {
if status.JobID == job.ID {
probe, err = declaredDependencyProbe(resolution.plugin, status.ProbeKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
break
}
}
}
digest := dependencyPlanDigest(resolution, probe, plan)
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if status.PlanDigest != digest {
return domain.DependencyExecutionInput{}, validationError("dependency declaration changed after dispatch")
}
bindings, err := dependencyBindings(resolution.binding, probe, plan)
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
}
func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) {
if err := validator.ValidateRunUpdateInputRequest(request); err != nil {
return domain.RunUpdateInput{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.RunUpdateInput{}, err
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return domain.RunUpdateInput{}, validationError("job is not a Run self-update")
}
update, distribution, artifact, err := svc.resolveRunUpdate(job)
if err != nil {
return domain.RunUpdateInput{}, err
}
return domain.RunUpdateInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, ArtifactID: artifact.ID, Checksum: artifact.Checksum, SizeBytes: artifact.SizeBytes, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, PackageFormat: distribution.PackageFormat, ExecutableName: executableFilename("run", distribution.TargetOS), TargetRelease: update.TargetRelease, ChunkSizeBytes: runUpdateChunkSize}, nil
}
func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
return domain.RunUpdateChunk{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.RunUpdateChunk{}, err
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return domain.RunUpdateChunk{}, validationError("job is not a Run self-update")
}
_, _, artifact, err := svc.resolveRunUpdate(job)
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")
}
length := request.Length
remaining := artifact.SizeBytes - request.Offset
if int64(length) > remaining {
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
}
func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) {
session, err := svc.validatedRunSession(endpointID, sessionToken)
if err != nil {
return domain.Job{}, err
}
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, err := svc.fencedJob(session, jobID, leaseToken, attempt)
if err != nil {
return domain.Job{}, err
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.Job{}, validationError("job input is not active")
}
if !job.CancelRequestedAt.IsZero() {
return domain.Job{}, validationError("job input is cancelled")
}
return job, nil
}
func (svc *CoreService) resolveDependencyContext(serverInstanceID string) (dependencyResolution, error) {
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return dependencyResolution{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return dependencyResolution{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion {
return dependencyResolution{}, validationError("installed plugin version does not match server")
}
binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil {
return dependencyResolution{}, err
}
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return dependencyResolution{}, err
}
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
return dependencyResolution{}, validationError("runtime binding is incomplete or stale")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return dependencyResolution{}, err
}
if endpoint.Platform == "" || endpoint.Architecture == "" {
return dependencyResolution{}, validationError("Run endpoint target is not registered")
}
return dependencyResolution{instance: instance, plugin: plugin, binding: binding, endpoint: endpoint}, nil
}
func declaredDependencyProbe(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeDependencyProbe, error) {
for _, probe := range plugin.RuntimeProfiles.DependencyProbes {
if probe.Key == key && runtimePlatformsContain(probe.Platforms, targetOS) {
return probe, nil
}
}
return domain.RuntimeDependencyProbe{}, validationError("dependency probe is not declared for endpoint target")
}
func declaredInstallPlan(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeInstallPlan, error) {
for _, plan := range plugin.RuntimeProfiles.InstallPlans {
if plan.Key == key && runtimePlatformsContain(plan.Platforms, targetOS) {
return plan, nil
}
}
return domain.RuntimeInstallPlan{}, validationError("dependency install plan is not declared for endpoint target")
}
func runtimePlatformsContain(platforms []string, target string) bool {
if len(platforms) == 0 {
return true
}
for _, platform := range platforms {
if platform == target {
return true
}
}
return false
}
func planTargetsProbe(plan domain.RuntimeInstallPlan, probe domain.RuntimeDependencyProbe) bool {
for _, step := range plan.Steps {
if step.TargetKey == probe.TargetKey {
return true
}
}
return false
}
func dependencyPlanDigest(resolution dependencyResolution, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) string {
keys := make([]string, 0, len(resolution.binding.Bindings))
for key := range resolution.binding.Bindings {
keys = append(keys, key)
}
sort.Strings(keys)
bindingEvidence := make([]string, 0, len(keys))
for _, key := range keys {
bindingEvidence = append(bindingEvidence, key+"="+validator.BytesChecksum([]byte(resolution.binding.Bindings[key])))
}
payload := struct {
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Binding []string `json:"binding"`
Probe domain.RuntimeDependencyProbe `json:"probe"`
Plan domain.RuntimeInstallPlan `json:"plan"`
}{resolution.plugin.ID, resolution.plugin.Version, resolution.binding.ProfileKey, resolution.endpoint.Platform, resolution.endpoint.Architecture, bindingEvidence, probe, plan}
body, _ := json.Marshal(payload)
return validator.BytesChecksum(body)
}
func dependencyBindings(binding domain.RuntimeBinding, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) (map[string]string, error) {
keys := map[string]struct{}{}
if probe.TargetKey != "" {
keys[probe.TargetKey] = struct{}{}
}
for _, step := range plan.Steps {
if step.TargetKey != "" {
keys[step.TargetKey] = struct{}{}
}
}
out := make(map[string]string, len(keys))
for key := range keys {
value := strings.TrimSpace(binding.Bindings[key])
if value == "" {
value = key
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "secret://") || strings.Contains(lower, "password=") || strings.Contains(lower, "token=") {
return nil, validationError("dependency target binding cannot be a secret")
}
out[key] = value
}
return out, nil
}
func (svc *CoreService) dependencyStatusForJob(jobID, serverInstanceID string) (domain.DependencyStatus, error) {
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.DependencyStatus{}, err
}
for _, status := range statuses {
if status.JobID == jobID {
return status, nil
}
}
return domain.DependencyStatus{}, repo.ErrNotFound
}
func (svc *CoreService) resolveRunUpdate(job domain.Job) (domain.RunUpdateJob, domain.RunDistribution, domain.Artifact, error) {
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
var update domain.RunUpdateJob
for _, candidate := range updates {
if candidate.JobID == job.ID {
update = candidate
break
}
}
if update.ID == "" || update.RunEndpointID != job.RunEndpointID {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update record does not match active job")
}
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID, Status: domain.DistributionStatusAvailable})
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
var distribution domain.RunDistribution
for _, candidate := range distributions {
if candidate.ArtifactID == update.ArtifactID {
distribution = candidate
break
}
}
if distribution.ID == "" || distribution.RunEndpointID != job.RunEndpointID || distribution.TargetOS != update.TargetOS || distribution.TargetArch != update.TargetArch || distribution.Checksum != update.Checksum {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run distribution no longer matches update")
}
endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID)
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
if endpoint.Platform != distribution.TargetOS || endpoint.Architecture != distribution.TargetArch {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update target no longer matches endpoint")
}
artifact, err := svc.store.Artifacts().Get(update.ArtifactID)
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID || artifact.Checksum != update.Checksum {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update artifact is unavailable or outside distribution scope")
}
return update, distribution, artifact, nil
}
func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stamp time.Time) error {
if job.Capability == domain.JobCapabilityDependenciesCheck || job.Capability == domain.JobCapabilityDependenciesInstall {
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
if err != nil {
return err
}
status.UpdatedAt = stamp
status.CheckedAt = stamp
status.JobID = job.ID
if job.State == domain.JobStateSucceeded {
var evidence domain.DependencyExecutionEvidence
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil {
return validationError("dependency result evidence is invalid")
}
if evidence.ProbeKey != status.ProbeKey || evidence.PlanDigest != status.PlanDigest || job.ExecutionResult.Checksum != status.PlanDigest {
return validationError("dependency result evidence does not match approved plan")
}
status.State = domain.DependencyState(evidence.State)
status.Evidence = evidence.Evidence
status.CompletedSteps = evidence.CompletedSteps
status.Message = "dependency execution completed"
} else if job.State == domain.JobStateCancelled {
status.State = domain.DependencyStateFailed
status.Message = "dependency execution cancelled"
} else {
status.State = domain.DependencyStateFailed
status.Message = "dependency execution failed"
}
if err := validator.ValidateDependencyStatus(status); err != nil {
return err
}
if err := svc.store.DependencyStatuses().Update(status); err != nil {
return err
}
return svc.recordAuditEvent("run", "dependency.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), status.Message)
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return nil
}
update, _, _, err := svc.resolveRunUpdate(job)
if err != nil {
return err
}
update.UpdatedAt = stamp
if job.State == domain.JobStateSucceeded {
var evidence domain.RunUpdateExecutionEvidence
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil || evidence.TargetRelease != update.TargetRelease || evidence.Phase != "staged" || job.ExecutionResult.Checksum != update.Checksum {
return validationError("Run update staged evidence is invalid")
}
update.Status = domain.DistributionJobStatusRunning
update.Phase = domain.RunUpdatePhaseRestartRequested
update.Message = "verified update staged; restart requested"
} else if job.State == domain.JobStateCancelled {
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseFailed
update.Message = "Run update cancelled before activation"
} else {
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseFailed
update.Message = "Run update verification or staging failed"
}
if err := validator.ValidateRunUpdateJob(update); err != nil {
return err
}
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
return err
}
return svc.recordAuditEvent("run", "run.update.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), update.Message)
}
func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error {
if job.Capability == domain.JobCapabilityRunSelfUpdate {
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, update := range updates {
if update.JobID != job.ID || update.Status != domain.DistributionJobStatusQueued {
continue
}
update.Status = domain.DistributionJobStatusRunning
update.Phase = domain.RunUpdatePhaseDownloading
update.Message = "Run is downloading and verifying the update"
update.UpdatedAt = stamp
if err := validator.ValidateRunUpdateJob(update); err != nil {
return err
}
return svc.store.RunUpdateJobs().Update(update)
}
}
return nil
}
func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) {
if err := validator.ValidateRunUpdateHealthReport(report); err != nil {
return domain.RunUpdateHealthResult{}, err
}
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
return domain.RunUpdateHealthResult{}, err
}
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, err := svc.store.Jobs().Get(report.JobID)
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
if job.RunEndpointID != report.RunEndpointID || job.Capability != domain.JobCapabilityRunSelfUpdate || job.State != domain.JobStateSucceeded || job.Attempt != report.Attempt || !leaseTokenMatches(job.LeaseTokenHash, report.LeaseToken) {
return domain.RunUpdateHealthResult{}, validationError("Run update health report does not match terminal attempt")
}
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
var update domain.RunUpdateJob
for _, candidate := range updates {
if candidate.JobID == job.ID && candidate.RunEndpointID == report.RunEndpointID {
update = candidate
break
}
}
if update.ID == "" || job.ExecutionResult.Checksum != update.Checksum {
return domain.RunUpdateHealthResult{}, validationError("Run update health report does not match staged update")
}
endpoint, err := svc.store.RunEndpoints().Get(report.RunEndpointID)
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
if endpoint.Version != report.Version || endpoint.Status != domain.RunEndpointStatusOnline {
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match online endpoint")
}
stamp := svc.now()
if report.Outcome == "succeeded" {
if report.Version != update.TargetRelease || update.Phase == domain.RunUpdatePhaseRolledBack || update.Phase == domain.RunUpdatePhaseFailed {
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match target release")
}
if update.Phase == domain.RunUpdatePhaseSucceeded {
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
update.Status = domain.DistributionJobStatusSucceeded
update.Phase = domain.RunUpdatePhaseSucceeded
update.Rollback = false
update.Message = "updated Run registered, reconciled, and reported healthy"
} else {
if update.PreviousVersion != "" && report.Version != update.PreviousVersion {
return domain.RunUpdateHealthResult{}, validationError("rolled-back Run version does not match previous release")
}
if update.Phase == domain.RunUpdatePhaseRolledBack {
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseRolledBack
update.Rollback = true
update.Message = "Run update activation failed and previous executable was restored"
}
update.UpdatedAt = stamp
if err := validator.ValidateRunUpdateJob(update); err != nil {
return domain.RunUpdateHealthResult{}, err
}
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
return domain.RunUpdateHealthResult{}, err
}
auditResult := domain.AuditResultSuccess
if report.Outcome == "rolled-back" {
auditResult = domain.AuditResultFailed
}
if err := svc.recordAuditEvent("run", "run.update.health", "server-instance", update.ServerInstanceID, auditResult, update.Message); err != nil {
return domain.RunUpdateHealthResult{}, err
}
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
func auditResultForJob(job domain.Job) domain.AuditResult {
if job.State == domain.JobStateSucceeded {
return domain.AuditResultSuccess
}
if job.State == domain.JobStateCancelled {
return domain.AuditResultDenied
}
return domain.AuditResultFailed
}
func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey
}
func findRunDistributionForArtifact(distributions []domain.RunDistribution, artifactID string) (domain.RunDistribution, error) {
for _, distribution := range distributions {
if distribution.ArtifactID == artifactID && distribution.Status == domain.DistributionStatusAvailable {
return distribution, nil
}
}
return domain.RunDistribution{}, errors.New("available Run distribution not found")
}
+290
View File
@@ -0,0 +1,290 @@
package service
import (
"encoding/json"
"errors"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "dependency-other-owner", DisplayName: "Other Owner", Email: "dependency-other@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetDependencyCatalogForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected cross-owner dependency catalog denial, got %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
if catalog.TargetOS != "linux" || catalog.TargetArch != "amd64" || len(catalog.Probes) != 1 || len(catalog.Plans) != 1 || !strings.HasPrefix(catalog.Plans[0].Digest, "sha256:") {
t.Fatalf("unexpected dependency catalog: %+v", catalog)
}
request := domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, Install: true, InstallPlanKey: catalog.Plans[0].Key, PlanDigest: "sha256:" + strings.Repeat("f", 64), IdempotencyKey: "dependency-stale-digest"}
if _, err := svc.QueueDependencyJobForSession(session, request); err == nil || !strings.Contains(err.Error(), "planDigest") {
t.Fatalf("expected stale digest rejection, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
for _, job := range jobs {
if job.IdempotencyKey == request.IdempotencyKey {
t.Fatalf("stale digest created a job: %+v", job)
}
}
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
if err != nil {
t.Fatalf("list audits: %v", err)
}
foundDenied := false
for _, audit := range audits {
foundDenied = foundDenied || audit.Action == "dependency.install.denied"
}
if !foundDenied {
t.Fatalf("expected stale digest audit, got %+v", audits)
}
request.PlanDigest = catalog.Plans[0].Digest
request.IdempotencyKey = "dependency-current-digest"
job, err := svc.QueueDependencyJobForSession(session, request)
if err != nil {
t.Fatalf("queue reviewed dependency plan: %v", err)
}
if job.Capability != domain.JobCapabilityDependenciesInstall || job.TargetKey != "dependencies/install/"+catalog.Plans[0].Key {
t.Fatalf("unexpected dependency install job: %+v", job)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.RuntimeProfiles.InstallPlans[0].Steps[0].PackageName = "openjdk-22-jre"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("mutate plugin declaration: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob {
t.Fatalf("claim dependency install: claim=%+v err=%v", claim, err)
}
_, 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 || !strings.Contains(err.Error(), "changed after dispatch") {
t.Fatalf("expected changed declaration rejection, got %v", err)
}
}
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("catalog: %v", err)
}
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-fencing"})
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)
}
base := domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetDependencyExecutionInput(base)
if err != nil || input.PlanDigest == "" || input.Bindings["java"] == "" {
t.Fatalf("get fenced dependency input: input=%+v err=%v", input, err)
}
wrongSession := base
wrongSession.SessionToken = "stale-session"
if _, err := svc.GetDependencyExecutionInput(wrongSession); err == nil {
t.Fatal("expected wrong session rejection")
}
wrongAttempt := base
wrongAttempt.Attempt++
if _, err := svc.GetDependencyExecutionInput(wrongAttempt); err == nil {
t.Fatal("expected wrong attempt rejection")
}
wrongLease := base
wrongLease.LeaseToken = "stale-lease"
if _, err := svc.GetDependencyExecutionInput(wrongLease); err == nil {
t.Fatal("expected wrong lease rejection")
}
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, AuditSummary: "dependency probe completed", Content: string(evidence)}}); err != nil {
t.Fatalf("complete dependency result: %v", err)
}
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil || projected.Probes[0].State != domain.DependencyStatePresent || projected.Probes[0].Evidence != "OpenJDK 21" {
t.Fatalf("unexpected dependency projection: catalog=%+v err=%v", projected, err)
}
cancelJob, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-cancel"})
if err != nil {
t.Fatalf("queue cancellable dependency check: %v", err)
}
claim, err = svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || claim.Job.JobID != cancelJob.ID {
t.Fatalf("claim cancellable dependency check: claim=%+v err=%v", claim, err)
}
if _, err := svc.RequestRunJobCancelForSession(session, domain.RunJobCancelRequest{JobID: cancelJob.ID, Reason: "operator cancelled"}); err != nil {
t.Fatalf("request cancel: %v", err)
}
if _, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("expected cancelled input rejection, got %v", err)
}
}
func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{Key: "runtime", Title: "Runtime", Path: "/runtime", Permissions: []string{"server.dependencies.manage"}, BridgeActions: []string{string(domain.PluginBridgeActionDependenciesRequest)}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("add dependency bridge page: %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
request := domain.PluginBridgeExecuteRequest{RequestID: "bridge-dependency-install", PluginID: plugin.ID, RouteKey: "runtime", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionDependenciesRequest, Payload: map[string]string{"operation": "install", "probeKey": catalog.Probes[0].Key, "planKey": catalog.Plans[0].Key, "idempotencyKey": "bridge-dependency-install"}}
denied, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute bridge without digest: %v", err)
}
if denied.Status == "queued" || denied.Error == nil {
t.Fatalf("bridge install without reviewed digest must be denied: %+v", denied)
}
request.Payload["planDigest"] = catalog.Plans[0].Digest
request.Payload["idempotencyKey"] = "bridge-dependency-install-approved"
approved, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute reviewed bridge install: %v", err)
}
if approved.Status != "queued" || approved.Result["capability"] != domain.JobCapabilityDependenciesInstall {
t.Fatalf("expected reviewed bridge dependency job, got %+v", approved)
}
}
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"})
if err != nil {
t.Fatalf("generate update distribution: %v", err)
}
payload := []byte("compiled target-matched run archive")
distribution = completeDistributionBuild(t, svc, distribution, payload)
otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create other update server: %v", err)
}
createCompleteRuntimeBinding(t, svc, otherInstance, "local")
if _, err := svc.PushRunUpdateForSession(session, domain.RunUpdateRequest{ServerInstanceID: otherInstance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-cross-server"}); err == nil {
t.Fatal("expected cross-server update artifact rejection")
}
endpoint, _ := svc.store.RunEndpoints().Get(instance.RunEndpointID)
endpoint.Architecture = "arm64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("change endpoint target: %v", err)
}
request := domain.RunUpdateRequest{ServerInstanceID: instance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-target-check"}
if _, err := svc.PushRunUpdateForSession(session, request); err == nil || !strings.Contains(err.Error(), "target-matched") {
t.Fatalf("expected cross-target update rejection, got %v", err)
}
endpoint.Architecture = "amd64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("restore endpoint target: %v", err)
}
request.IdempotencyKey = "run-update-fenced"
update, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push target-matched update: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != update.JobID {
t.Fatalf("claim Run update: claim=%+v err=%v", claim, err)
}
inputRequest := domain.RunUpdateInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetRunUpdateInput(inputRequest)
if err != nil || input.TargetRelease != update.TargetRelease || input.Checksum != distribution.Checksum {
t.Fatalf("get Run update input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 8})
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)
}
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")
}
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), AuditSummary: "verified update staged", Content: string(evidence)}}); err != nil {
t.Fatalf("complete staged Run update: %v", err)
}
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
if err != nil || len(updates) != 1 || updates[0].Phase != domain.RunUpdatePhaseRestartRequested {
t.Fatalf("expected restart-requested projection, updates=%+v err=%v", updates, err)
}
newHello := dependencyUpdateHello(instance)
newHello.Version = update.TargetRelease
newRegistration, err := svc.RegisterRunHello(newHello)
if err != nil {
t.Fatalf("register updated Run: %v", err)
}
health := domain.RunUpdateHealthReport{RunEndpointID: instance.RunEndpointID, SessionToken: newRegistration.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Outcome: "succeeded", Version: update.TargetRelease}
if _, err := svc.ReportRunUpdateHealth(domain.RunUpdateHealthReport{RunEndpointID: health.RunEndpointID, SessionToken: health.SessionToken, JobID: health.JobID, LeaseToken: "stale-lease", Attempt: health.Attempt, Outcome: health.Outcome, Version: health.Version}); err == nil {
t.Fatal("expected stale health lease rejection")
}
result, err := svc.ReportRunUpdateHealth(health)
if err != nil || !result.Accepted || result.Phase != domain.RunUpdatePhaseSucceeded {
t.Fatalf("report updated Run health: result=%+v err=%v", result, err)
}
rollbackHello := dependencyUpdateHello(instance)
rollbackHello.Version = update.PreviousVersion
rollbackRegistration, err := svc.RegisterRunHello(rollbackHello)
if err != nil {
t.Fatalf("register rolled-back Run: %v", err)
}
health.SessionToken = rollbackRegistration.SessionToken
health.Outcome = "rolled-back"
health.Version = update.PreviousVersion
result, err = svc.ReportRunUpdateHealth(health)
if err != nil || result.Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("report rollback: result=%+v err=%v", result, err)
}
updates, _ = svc.ListRunUpdateJobsForSession(session, instance.ID)
if !updates[0].Rollback || updates[0].Status != domain.DistributionJobStatusFailed || updates[0].Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("unexpected rollback projection: %+v", updates[0])
}
}
func registerDependencyUpdateRun(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
result, err := svc.RegisterRunHello(dependencyUpdateHello(instance))
if err != nil || !result.Accepted {
t.Fatalf("register dependency/update Run: result=%+v err=%v", result, err)
}
return result.SessionToken
}
func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHello {
return domain.RunControlHello{
RegistrationToken: "registration-token",
RunEndpointID: instance.RunEndpointID,
DisplayName: "Dependency Update Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "linux",
Architecture: "amd64",
CapabilityReport: domain.RunCapabilityReport{Capabilities: []string{domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Fingerprint: "dependency-update-v1"},
Capacity: domain.RunCapacity{MaxJobs: 2},
}
}
+61 -4
View File
@@ -14,12 +14,13 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if err := validator.ValidateDistributionBuildInputRequest(request); err != nil { if err := validator.ValidateDistributionBuildInputRequest(request); err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
if err := svc.validateRunSession(request.RunEndpointID, request.SessionToken); err != nil { session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
svc.jobMu.Lock() svc.jobMu.Lock()
job, _, err := svc.activeLeasedJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
svc.jobMu.Unlock() svc.jobMu.Unlock()
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
@@ -46,7 +47,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if key.Generation != distribution.KeyGeneration { if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current") return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current")
} }
plainKey, err := decryptRuntimeKey(key.EncryptedKey) plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
@@ -58,6 +59,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
RunEndpointID: distribution.RunEndpointID, RunEndpointID: distribution.RunEndpointID,
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
TargetRelease: distribution.ID,
PackageFormat: distribution.PackageFormat, PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID, ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS), OutputFilename: executableFilename("run", distribution.TargetOS),
@@ -82,7 +84,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if key.Generation != distribution.KeyGeneration { if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current") return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
} }
plainKey, err := decryptRuntimeKey(key.EncryptedKey) plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil { if err != nil {
return domain.DistributionBuildInput{}, err return domain.DistributionBuildInput{}, err
} }
@@ -134,6 +136,9 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
if job.Capability != domain.JobCapabilityDistributionBuild { if job.Capability != domain.JobCapabilityDistributionBuild {
return nil return nil
} }
if err := svc.validateDistributionBuildResult(job); err != nil {
return err
}
status := domain.DistributionStatusFailed status := domain.DistributionStatusFailed
buildStatus := domain.DistributionJobStatusFailed buildStatus := domain.DistributionJobStatusFailed
var artifact domain.Artifact var artifact domain.Artifact
@@ -198,6 +203,9 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil { if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
return err return err
} }
if err := svc.ProjectClientManagerDistribution(distribution); err != nil {
return err
}
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID) build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) { if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err return err
@@ -221,6 +229,55 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim
return repo.ErrNotFound return repo.ErrNotFound
} }
func (svc *CoreService) validateDistributionBuildResult(job domain.Job) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return nil
}
expectedArtifactID := ""
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range runDistributions {
if distribution.BuildJobID == job.ID {
expectedArtifactID = distribution.ArtifactID
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
}
if job.State != domain.JobStateSucceeded {
return nil
}
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
if artifactID == "" || artifactID == job.ResultRef || artifactID != expectedArtifactID {
return validationError("distribution build result must reference the expected artifact")
}
artifact, err := svc.store.Artifacts().Get(artifactID)
if err != nil {
return err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
return validationError("distribution build artifact is unavailable or outside the job scope")
}
return nil
}
func executableFilename(base string, targetOS string) string { func executableFilename(base string, targetOS string) string {
if targetOS == "windows" { if targetOS == "windows" {
return base + ".exe" return base + ".exe"

Some files were not shown because too many files have changed in this diff Show More