diff --git a/docker-compose.yml b/docker-compose.yml index 8a484a3..a4dbd57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: # MySQL should store metadata/cursors/indexes, not one row per log line. PLATFORM_LOG_BODY_BACKEND: file 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: - "8080:8080" volumes: diff --git a/openspec/changes/add-run-distribution-and-client-managers/tasks.md b/openspec/changes/add-run-distribution-and-client-managers/tasks.md index 336975e..e3f3e4a 100644 --- a/openspec/changes/add-run-distribution-and-client-managers/tasks.md +++ b/openspec/changes/add-run-distribution-and-client-managers/tasks.md @@ -55,11 +55,11 @@ ## 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. -- [ ] 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. -- [ ] 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.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.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.3 Drive the platform_web generation dialog from real job progress and terminal state instead of timer-completed stages. +- [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. +- [x] 8.5 Run focused platform, run, frontend, OpenSpec, and structure verification and record the evidence below. ## 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. - 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. + +### 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. diff --git a/openspec/changes/harden-platform-auth-and-secret-persistence/design.md b/openspec/changes/harden-platform-auth-and-secret-persistence/design.md new file mode 100644 index 0000000..da71ca0 --- /dev/null +++ b/openspec/changes/harden-platform-auth-and-secret-persistence/design.md @@ -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. diff --git a/openspec/changes/harden-platform-auth-and-secret-persistence/proposal.md b/openspec/changes/harden-platform-auth-and-secret-persistence/proposal.md new file mode 100644 index 0000000..18500ae --- /dev/null +++ b/openspec/changes/harden-platform-auth-and-secret-persistence/proposal.md @@ -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. diff --git a/openspec/changes/harden-platform-auth-and-secret-persistence/specs/platform-auth-secrets/spec.md b/openspec/changes/harden-platform-auth-and-secret-persistence/specs/platform-auth-secrets/spec.md new file mode 100644 index 0000000..119f68c --- /dev/null +++ b/openspec/changes/harden-platform-auth-and-secret-persistence/specs/platform-auth-secrets/spec.md @@ -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. diff --git a/openspec/changes/harden-platform-auth-and-secret-persistence/tasks.md b/openspec/changes/harden-platform-auth-and-secret-persistence/tasks.md new file mode 100644 index 0000000..5759385 --- /dev/null +++ b/openspec/changes/harden-platform-auth-and-secret-persistence/tasks.md @@ -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. diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/.openspec.yaml b/openspec/changes/implement-dependency-installation-and-run-self-update/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/design.md b/openspec/changes/implement-dependency-installation-and-run-self-update/design.md new file mode 100644 index 0000000..d3c611a --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/design.md @@ -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. diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/proposal.md b/openspec/changes/implement-dependency-installation-and-run-self-update/proposal.md new file mode 100644 index 0000000..6eb2b5c --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/proposal.md @@ -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. diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/specs/artifact-transfer-channel/spec.md b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/artifact-transfer-channel/spec.md new file mode 100644 index 0000000..c44e7ac --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/artifact-transfer-channel/spec.md @@ -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 diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/specs/durable-dependency-execution/spec.md b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/durable-dependency-execution/spec.md new file mode 100644 index 0000000..b704101 --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/durable-dependency-execution/spec.md @@ -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 diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/specs/run-distribution-and-client-managers/spec.md b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/run-distribution-and-client-managers/spec.md new file mode 100644 index 0000000..610fcd2 --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/run-distribution-and-client-managers/spec.md @@ -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 diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/specs/transactional-run-self-update/spec.md b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/transactional-run-self-update/spec.md new file mode 100644 index 0000000..11530ee --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/specs/transactional-run-self-update/spec.md @@ -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 diff --git a/openspec/changes/implement-dependency-installation-and-run-self-update/tasks.md b/openspec/changes/implement-dependency-installation-and-run-self-update/tasks.md new file mode 100644 index 0000000..33d9a01 --- /dev/null +++ b/openspec/changes/implement-dependency-installation-and-run-self-update/tasks.md @@ -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. diff --git a/openspec/changes/implement-durable-job-scheduling-and-reconciliation/.openspec.yaml b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/implement-durable-job-scheduling-and-reconciliation/design.md b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/design.md new file mode 100644 index 0000000..dd96110 --- /dev/null +++ b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/design.md @@ -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. diff --git a/openspec/changes/implement-durable-job-scheduling-and-reconciliation/proposal.md b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/proposal.md new file mode 100644 index 0000000..e179e71 --- /dev/null +++ b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/proposal.md @@ -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. diff --git a/openspec/changes/implement-durable-job-scheduling-and-reconciliation/specs/durable-job-scheduling/spec.md b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/specs/durable-job-scheduling/spec.md new file mode 100644 index 0000000..12935d9 --- /dev/null +++ b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/specs/durable-job-scheduling/spec.md @@ -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 diff --git a/openspec/changes/implement-durable-job-scheduling-and-reconciliation/tasks.md b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/tasks.md new file mode 100644 index 0000000..e4edcd4 --- /dev/null +++ b/openspec/changes/implement-durable-job-scheduling-and-reconciliation/tasks.md @@ -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. diff --git a/openspec/changes/implement-durable-observability-and-remote-adapters/design.md b/openspec/changes/implement-durable-observability-and-remote-adapters/design.md new file mode 100644 index 0000000..320b5b6 --- /dev/null +++ b/openspec/changes/implement-durable-observability-and-remote-adapters/design.md @@ -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. diff --git a/openspec/changes/implement-durable-observability-and-remote-adapters/proposal.md b/openspec/changes/implement-durable-observability-and-remote-adapters/proposal.md new file mode 100644 index 0000000..4450aee --- /dev/null +++ b/openspec/changes/implement-durable-observability-and-remote-adapters/proposal.md @@ -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. diff --git a/openspec/changes/implement-durable-observability-and-remote-adapters/specs/durable-observability/spec.md b/openspec/changes/implement-durable-observability-and-remote-adapters/specs/durable-observability/spec.md new file mode 100644 index 0000000..bc194d1 --- /dev/null +++ b/openspec/changes/implement-durable-observability-and-remote-adapters/specs/durable-observability/spec.md @@ -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 diff --git a/openspec/changes/implement-durable-observability-and-remote-adapters/specs/scoped-remote-adapters/spec.md b/openspec/changes/implement-durable-observability-and-remote-adapters/specs/scoped-remote-adapters/spec.md new file mode 100644 index 0000000..aed5f00 --- /dev/null +++ b/openspec/changes/implement-durable-observability-and-remote-adapters/specs/scoped-remote-adapters/spec.md @@ -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 diff --git a/openspec/changes/implement-durable-observability-and-remote-adapters/tasks.md b/openspec/changes/implement-durable-observability-and-remote-adapters/tasks.md new file mode 100644 index 0000000..4880cb4 --- /dev/null +++ b/openspec/changes/implement-durable-observability-and-remote-adapters/tasks.md @@ -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. diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/.openspec.yaml b/openspec/changes/implement-real-process-supervision-and-config-file-execution/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/design.md b/openspec/changes/implement-real-process-supervision-and-config-file-execution/design.md new file mode 100644 index 0000000..aa918b3 --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/design.md @@ -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 `/instances//`, 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. diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/proposal.md b/openspec/changes/implement-real-process-supervision-and-config-file-execution/proposal.md new file mode 100644 index 0000000..184d06b --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/proposal.md @@ -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. diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/bounded-process-supervision/spec.md b/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/bounded-process-supervision/spec.md new file mode 100644 index 0000000..2f04a05 --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/bounded-process-supervision/spec.md @@ -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 + diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/scoped-config-file-execution/spec.md b/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/scoped-config-file-execution/spec.md new file mode 100644 index 0000000..93d7ddb --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/specs/scoped-config-file-execution/spec.md @@ -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 diff --git a/openspec/changes/implement-real-process-supervision-and-config-file-execution/tasks.md b/openspec/changes/implement-real-process-supervision-and-config-file-execution/tasks.md new file mode 100644 index 0000000..1bf0174 --- /dev/null +++ b/openspec/changes/implement-real-process-supervision-and-config-file-execution/tasks.md @@ -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. diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/.openspec.yaml b/openspec/changes/implement-secure-client-manager-lifecycle/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/design.md b/openspec/changes/implement-secure-client-manager-lifecycle/design.md new file mode 100644 index 0000000..1944f7e --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/design.md @@ -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/` 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. diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/proposal.md b/openspec/changes/implement-secure-client-manager-lifecycle/proposal.md new file mode 100644 index 0000000..ed51ddf --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/proposal.md @@ -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. diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/specs/run-distribution-and-client-managers/spec.md b/openspec/changes/implement-secure-client-manager-lifecycle/specs/run-distribution-and-client-managers/spec.md new file mode 100644 index 0000000..c0d64c8 --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/specs/run-distribution-and-client-managers/spec.md @@ -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 diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/specs/secure-client-manager-lifecycle/spec.md b/openspec/changes/implement-secure-client-manager-lifecycle/specs/secure-client-manager-lifecycle/spec.md new file mode 100644 index 0000000..8164e54 --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/specs/secure-client-manager-lifecycle/spec.md @@ -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 diff --git a/openspec/changes/implement-secure-client-manager-lifecycle/tasks.md b/openspec/changes/implement-secure-client-manager-lifecycle/tasks.md new file mode 100644 index 0000000..c3241cd --- /dev/null +++ b/openspec/changes/implement-secure-client-manager-lifecycle/tasks.md @@ -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. diff --git a/openspec/changes/persist-runtime-profiles-and-server-bindings/.openspec.yaml b/openspec/changes/persist-runtime-profiles-and-server-bindings/.openspec.yaml new file mode 100644 index 0000000..ff5f854 --- /dev/null +++ b/openspec/changes/persist-runtime-profiles-and-server-bindings/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-17 diff --git a/openspec/changes/persist-runtime-profiles-and-server-bindings/design.md b/openspec/changes/persist-runtime-profiles-and-server-bindings/design.md new file mode 100644 index 0000000..6af6c1a --- /dev/null +++ b/openspec/changes/persist-runtime-profiles-and-server-bindings/design.md @@ -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. diff --git a/openspec/changes/persist-runtime-profiles-and-server-bindings/proposal.md b/openspec/changes/persist-runtime-profiles-and-server-bindings/proposal.md new file mode 100644 index 0000000..bdf5e66 --- /dev/null +++ b/openspec/changes/persist-runtime-profiles-and-server-bindings/proposal.md @@ -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. diff --git a/openspec/changes/persist-runtime-profiles-and-server-bindings/specs/runtime-profile-bindings/spec.md b/openspec/changes/persist-runtime-profiles-and-server-bindings/specs/runtime-profile-bindings/spec.md new file mode 100644 index 0000000..8216cdb --- /dev/null +++ b/openspec/changes/persist-runtime-profiles-and-server-bindings/specs/runtime-profile-bindings/spec.md @@ -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 diff --git a/openspec/changes/persist-runtime-profiles-and-server-bindings/tasks.md b/openspec/changes/persist-runtime-profiles-and-server-bindings/tasks.md new file mode 100644 index 0000000..8ee3773 --- /dev/null +++ b/openspec/changes/persist-runtime-profiles-and-server-bindings/tasks.md @@ -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. diff --git a/platform/.env.example b/platform/.env.example index 2392e69..a9c96e7 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -18,3 +18,12 @@ PLATFORM_METADATA_PATH=.platform-data/metadata.json # of server log lines into MySQL rows. PLATFORM_LOG_BODY_BACKEND=file 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 diff --git a/platform/README.md b/platform/README.md index 314d479..40d3f8d 100644 --- a/platform/README.md +++ b/platform/README.md @@ -50,6 +50,10 @@ Runtime configuration: - `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_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: @@ -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. -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. diff --git a/platform/api/authorization.go b/platform/api/authorization.go new file mode 100644 index 0000000..9d52fcf --- /dev/null +++ b/platform/api/authorization.go @@ -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 +} diff --git a/platform/api/authorization_test.go b/platform/api/authorization_test.go new file mode 100644 index 0000000..ec9fd0f --- /dev/null +++ b/platform/api/authorization_test.go @@ -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) + } +} diff --git a/platform/api/client_manager_lifecycle_handlers.go b/platform/api/client_manager_lifecycle_handlers.go new file mode 100644 index 0000000..04fa9d3 --- /dev/null +++ b/platform/api/client_manager_lifecycle_handlers.go @@ -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)) +} diff --git a/platform/api/job_channel_handlers_test.go b/platform/api/job_channel_handlers_test.go index 2cbb5b9..bbe3f4a 100644 --- a/platform/api/job_channel_handlers_test.go +++ b/platform/api/job_channel_handlers_test.go @@ -78,6 +78,7 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) { SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, }) assertStatus(t, pollRecorder, http.StatusOK) 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{ RunEndpointID: "run-local", SessionToken: hello.SessionToken, - ActiveJobIDs: []string{"local-only"}, + ActiveJobs: []dto.RunJobReconcileEntry{{JobID: "local-only", LeaseToken: "local-lease", Attempt: 1}}, }) assertStatus(t, reconcileRecorder, http.StatusOK) 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) } } diff --git a/platform/api/observability_handlers.go b/platform/api/observability_handlers.go new file mode 100644 index 0000000..885d9fc --- /dev/null +++ b/platform/api/observability_handlers.go @@ -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 +} diff --git a/platform/api/remote_adapter_handlers.go b/platform/api/remote_adapter_handlers.go new file mode 100644 index 0000000..1f0e6d6 --- /dev/null +++ b/platform/api/remote_adapter_handlers.go @@ -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") + } +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 6b35a17..edaeeaf 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -12,17 +12,19 @@ import ( ) type coreHandlers struct { - core service.Core + core service.Core + enforceAuthorization bool } -func newCoreHandlers(core service.Core) *coreHandlers { - return &coreHandlers{core: core} +func newCoreHandlers(core service.Core, enforceAuthorization bool) *coreHandlers { + return &coreHandlers{core: core, enforceAuthorization: enforceAuthorization} } func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/auth/register", h.authRegister) mux.HandleFunc("/api/v1/auth/login", h.authLogin) 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/profile", h.currentUserProfile) 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/metrics/platform", h.platformMetrics) 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/workflows/create", h.serverInstanceCreateWorkflow) mux.HandleFunc("/api/v1/server-instances/{id}/start", h.serverInstanceStart) mux.HandleFunc("/api/v1/server-instances/{id}/stop", h.serverInstanceStop) + mux.HandleFunc("/api/v1/server-instances/{id}/process/status", h.serverInstanceProcessStatus) mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions) + mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding) + 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/download", h.serverRunDownload) 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/download", h.serverClientManagerDownload) mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers", h.serverClientManagerLifecycles) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/{profileKey}", h.serverClientManagerLifecycleDetail) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/deploy", h.serverClientManagerDeploy) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/control", h.serverClientManagerControl) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/update", h.serverClientManagerUpdateLifecycle) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession) + mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) 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/backfill", h.serverLogsBackfill) 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}", h.serverInstanceDetail) mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello) - mux.HandleFunc("/api/v1/run/control/heartbeat", h.runControlHeartbeat) - mux.HandleFunc("/api/v1/run/jobs/claim", h.runJobClaim) - mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck) - mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress) - mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult) - mux.HandleFunc("/api/v1/run/jobs/build-input", h.runJobBuildInput) - mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll) - mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile) - mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest) - mux.HandleFunc("/api/v1/run/artifacts/open", h.runArtifactOpen) - mux.HandleFunc("/api/v1/run/artifacts/chunks", h.runArtifactChunkUpload) - mux.HandleFunc("/api/v1/run/artifacts/status", h.runArtifactStatus) - mux.HandleFunc("/api/v1/run/artifacts/complete", h.runArtifactComplete) + mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat)) + mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim)) + mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck)) + mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress)) + mux.HandleFunc("/api/v1/run/jobs/result", h.requireRunSignature(h.runJobResult)) + mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput)) + mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput)) + mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput)) + mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk)) + mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth)) + mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput)) + mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk)) + mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll)) + mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile)) + mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest)) + 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/{id}", h.runEndpointDetail) 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/audit-events", h.auditEvents) 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 @@ -126,7 +152,7 @@ func (h *coreHandlers) authRegister(w http.ResponseWriter, r *http.Request) { writeServiceError(w, err) return } - writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) + h.writeAuthSession(w, r, session) } // authLogin godoc @@ -157,7 +183,7 @@ func (h *coreHandlers) authLogin(w http.ResponseWriter, r *http.Request) { writeServiceError(w, err) return } - writeJSON(w, http.StatusOK, dto.AuthSessionFromDomain(session)) + h.writeAuthSession(w, r, session) } // authLogout godoc @@ -178,9 +204,32 @@ func (h *coreHandlers) authLogout(w http.ResponseWriter, r *http.Request) { writeServiceError(w, err) return } + h.clearSessionCookie(w, r) 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 // @Summary Get current platform user // @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 { const prefix = "Bearer " 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 header[len(prefix):] + return cookie.Value } // pluginBridgeAuthorize godoc @@ -302,7 +355,12 @@ func (h *coreHandlers) pluginBridgeAuthorize(w http.ResponseWriter, r *http.Requ writeDecodeError(w, err) 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 { writeServiceError(w, err) return @@ -530,7 +588,11 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) writeDecodeError(w, err) 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 { writeServiceError(w, err) return @@ -1418,6 +1480,93 @@ func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request) 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 // @Summary Poll run job cancellation // @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) { switch r.Method { case http.MethodGet: - jobs, err := h.core.ListJobs(domain.JobFilter{ + filter := domain.JobFilter{ ServerInstanceID: r.URL.Query().Get("serverInstanceId"), RunEndpointID: r.URL.Query().Get("runEndpointId"), 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 { writeServiceError(w, err) return @@ -1755,7 +1911,12 @@ func (h *coreHandlers) jobCancel(w http.ResponseWriter, r *http.Request) { return } 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 { writeServiceError(w, err) return @@ -1778,7 +1939,13 @@ func (h *coreHandlers) jobDetail(w http.ResponseWriter, r *http.Request) { writeMethodNotAllowed(w, http.MethodGet) 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 { writeServiceError(w, err) 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) { switch r.Method { case http.MethodGet: - artifacts, err := h.core.ListArtifacts(domain.ArtifactFilter{ + filter := domain.ArtifactFilter{ OwnerKind: domain.ArtifactOwnerKind(r.URL.Query().Get("ownerKind")), OwnerID: r.URL.Query().Get("ownerId"), 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 { writeServiceError(w, err) return @@ -1995,10 +2169,17 @@ func parseByteRange(header string) (int64, int, bool) { func (h *coreHandlers) logStreams(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ + filter := domain.LogStreamFilter{ ServerInstanceID: r.URL.Query().Get("serverInstanceId"), 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 { writeServiceError(w, err) return @@ -2043,7 +2224,12 @@ func (h *coreHandlers) logStreamQuery(w http.ResponseWriter, r *http.Request) { writeDecodeError(w, err) 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 { writeServiceError(w, err) return @@ -2066,7 +2252,13 @@ func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) { writeMethodNotAllowed(w, http.MethodGet) 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 { writeServiceError(w, err) return diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index ce5746c..4d16e33 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -36,8 +36,8 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) { assertListCount(t, users.Count, 2) providerResponse := createAIProviderFixture(t, router, adminSession) - if providerResponse.APIKeyRef != "secret://providers/openai" { - t.Fatalf("expected AI provider key reference, got %+v", providerResponse) + if !providerResponse.APIKeyConfigured { + t.Fatalf("expected AI provider key presence, got %+v", providerResponse) } 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) @@ -222,6 +222,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { Name: "Config API Server", State: domain.ServerInstanceStateRunning, }, 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) 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) 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) assertStatus(t, dependencyCheckRecorder, http.StatusAccepted) @@ -324,7 +338,11 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { if dependencyCheck.Capability != domain.JobCapabilityDependenciesCheck || dependencyCheck.TargetKey != "dependencies/java-runtime" { 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) dependencyInstall := decodeBody[dto.JobResponse](t, dependencyInstallRecorder) if dependencyInstall.Capability != domain.JobCapabilityDependenciesInstall || dependencyInstall.TargetKey != "dependencies/install/java-install" { @@ -466,22 +484,39 @@ func TestAuthSessionAPI(t *testing.T) { func TestDefaultRouterSeedsLocalPlatformAdmin(t *testing.T) { router, err := NewRouterFromConfig(config.Config{ - StorageBackend: "file", - MetadataPath: filepath.Join(t.TempDir(), "metadata.json"), - LogDir: filepath.Join(t.TempDir(), "logs"), + StorageBackend: "file", + MetadataPath: filepath.Join(t.TempDir(), "metadata.json"), + LogDir: filepath.Join(t.TempDir(), "logs"), + BootstrapAdminEmail: "operator.local@example.test", + BootstrapAdminPassword: "operator-local", }) if err != nil { 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", 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) } + 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) currentUser := decodeBody[dto.CurrentUserResponse](t, current) 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", Name: "SCUM Create", IdempotencyKey: "idem-create", + ProfileKey: "local", }, adminSession) if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall { t.Fatalf("expected create workflow response, got %+v", created) @@ -613,6 +649,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) { Name: "SCUM Ready", State: domain.ServerInstanceStateReady, }, 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{ ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-start", @@ -628,6 +665,7 @@ func TestServerLifecycleWorkflowAPI(t *testing.T) { Name: "SCUM Running", State: domain.ServerInstanceStateRunning, }, 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{ ExpectedConfigVersion: running.ConfigVersion, IdempotencyKey: "idem-stop", @@ -795,8 +833,8 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) { if _, exists := body["rawApiKey"]; exists { t.Fatalf("AI provider response must not expose rawApiKey: %+v", body) } - if body["apiKeyRef"] != "secret://providers/openai" { - t.Fatalf("expected apiKeyRef only, got %+v", body) + if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true { + 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) assertStatus(t, updatedRecorder, http.StatusOK) 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) } @@ -1133,6 +1171,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) { Name: "Bridge API Server", State: domain.ServerInstanceStateRunning, }, 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{ ID: "server-bridge-lifecycle-api", PluginID: "game.example", @@ -1140,6 +1179,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) { Name: "Bridge Lifecycle API Server", State: domain.ServerInstanceStateReady, }, 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{ ID: "log-bridge-api", 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) { router := newTestRouter() 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 { panic(err) } - return NewRouterWithCore(core) + return NewTestRouterWithCore(core) } 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 { @@ -1501,6 +1617,11 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, + domain.JobCapabilityClientManagerDeploy, + domain.JobCapabilityClientManagerControl, + domain.JobCapabilityClientManagerUpdate, + domain.JobCapabilityClientManagerRollback, + domain.JobCapabilityClientManagerUninstall, } pluginRequest.DeclaredPermissions = []string{ "server.read", @@ -1516,16 +1637,26 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st string(domain.PluginBridgeActionDependenciesRequest), string(domain.PluginBridgeActionLogsBackfillRequest), } + pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}} + pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}} + pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) endpoint := validRunEndpointRequest() endpoint.ID = "run-runtime" + endpoint.Platform = "linux" + endpoint.Architecture = "amd64" endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDistributionBuild, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, + domain.JobCapabilityClientManagerDeploy, + domain.JobCapabilityClientManagerControl, + domain.JobCapabilityClientManagerUpdate, + domain.JobCapabilityClientManagerRollback, + domain.JobCapabilityClientManagerUninstall, ) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint) @@ -1536,6 +1667,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st Name: "Runtime API Server", State: domain.ServerInstanceStateReady, }, 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{ ID: "log-runtime-api", ServerInstanceID: server.ID, @@ -1594,6 +1726,7 @@ func validGamePluginRequest() dto.GamePluginCreateRequest { Start: "actions/start.json", Stop: "actions/stop.json", }, + RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, } } @@ -1638,7 +1771,8 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)}, }, }, - 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"}}}}, }, } } diff --git a/platform/api/router.go b/platform/api/router.go index 5611dcf..6857d0c 100644 --- a/platform/api/router.go +++ b/platform/api/router.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "path/filepath" "strings" "browser.local/platform/config" @@ -27,18 +28,53 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) { if err != nil { return nil, err } - core := service.NewCoreServiceWithLogStore(store, logStore) - if err := core.SeedLocalPlatformAdmin(); err != nil { + artifactDir := strings.TrimSpace(cfg.ArtifactDir) + 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 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 { - 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.HandleFunc("/healthz", HealthHandler) handlers.register(mux) + if enforceAuthorization { + return handlers.requireAuthorizedAPI(mux) + } return mux } diff --git a/platform/api/routes.md b/platform/api/routes.md index d6adf82..80222ee 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -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 bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` | | Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` | -| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `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` | | 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` | @@ -43,13 +43,14 @@ All routes use JSON request and response bodies. Collection routes support `GET` ## 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/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/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. - `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`. -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 @@ -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 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 - `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 +- `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. - `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/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`. - `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`. +- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and 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/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`. - `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`. +- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. -- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and 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`. - `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. +`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 - `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 -- `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/ack`: accept `RunJobAckRequest` and move an active leased job into running state. -- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest` and update bounded progress metadata. -- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal job result. -- `POST /api/v1/run/jobs/cancel`: accept `RunJobCancelPollRequest` and return pending cancellation metadata for active leases. -- `POST /api/v1/run/jobs/reconcile`: accept `RunJobReconcileRequest` and return platform-known active jobs plus unknown run-reported job IDs. -- `POST /api/v1/jobs/{id}/cancel`: accept `RunJobCancelRequestBody` and record a platform cancellation request for run polling. +- `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`, 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`, 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 result or durable retry-wait transition with capped exponential backoff. +- `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 persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs. +- `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result. -Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt, 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. ## 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`. - `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 @@ -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. - 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. -- 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. - 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. ## Core Service Boundary diff --git a/platform/api/run_signature.go b/platform/api/run_signature.go new file mode 100644 index 0000000..e7bd586 --- /dev/null +++ b/platform/api/run_signature.go @@ -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) + } +} diff --git a/platform/api/server_lifecycle_handlers.go b/platform/api/server_lifecycle_handlers.go index d09f551..119f14a 100644 --- a/platform/api/server_lifecycle_handlers.go +++ b/platform/api/server_lifecycle_handlers.go @@ -98,3 +98,35 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request } 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)) +} diff --git a/platform/api/server_runtime_handlers.go b/platform/api/server_runtime_handlers.go index 9bf8b0e..39dff08 100644 --- a/platform/api/server_runtime_handlers.go +++ b/platform/api/server_runtime_handlers.go @@ -7,6 +7,48 @@ import ( "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) { if r.Method != 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) { + 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 { - writeMethodNotAllowed(w, http.MethodPost) + writeMethodNotAllowed(w, "GET, POST") return } 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)) } +// 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) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) diff --git a/platform/api/session_cookie.go b/platform/api/session_cookie.go new file mode 100644 index 0000000..30546c0 --- /dev/null +++ b/platform/api/session_cookie.go @@ -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, + }) +} diff --git a/platform/config/config.go b/platform/config/config.go index 8521128..8692da1 100644 --- a/platform/config/config.go +++ b/platform/config/config.go @@ -12,13 +12,17 @@ const defaultDataDir = ".platform-data" const defaultStorageBackend = "file" type Config struct { - Addr string - StorageBackend string - MySQLDSN string - DataDir string - MetadataPath string - LogDir string - LogBodyBackend string + Addr string + StorageBackend string + MySQLDSN string + DataDir string + MetadataPath string + LogDir string + ArtifactDir string + LogBodyBackend string + BootstrapAdminEmail string + BootstrapAdminPassword string + SecretEnvelopeKey string } func Load() Config { @@ -40,6 +44,10 @@ func Load() Config { if logDir == "" { 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")) if storageBackend == "" { storageBackend = defaultStorageBackend @@ -47,13 +55,17 @@ func Load() Config { logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND")) return Config{ - Addr: addr, - StorageBackend: storageBackend, - MySQLDSN: strings.TrimSpace(os.Getenv("PLATFORM_MYSQL_DSN")), - DataDir: dataDir, - MetadataPath: metadataPath, - LogDir: logDir, - LogBodyBackend: logBodyBackend, + Addr: addr, + StorageBackend: storageBackend, + MySQLDSN: strings.TrimSpace(os.Getenv("PLATFORM_MYSQL_DSN")), + DataDir: dataDir, + MetadataPath: metadataPath, + LogDir: logDir, + ArtifactDir: artifactDir, + 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"), } } diff --git a/platform/config/config_test.go b/platform/config/config_test.go index 91c56a5..65140bf 100644 --- a/platform/config/config_test.go +++ b/platform/config/config_test.go @@ -15,6 +15,9 @@ func TestLoadUsesDefaultAddress(t *testing.T) { t.Setenv("PLATFORM_METADATA_PATH", "") t.Setenv("PLATFORM_LOG_DIR", "") 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() 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_LOG_DIR", "/tmp/platform-logs") 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() if cfg.Addr != ":18080" { 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) } } @@ -97,6 +103,9 @@ func clearPlatformEnv(t *testing.T) { "PLATFORM_METADATA_PATH", "PLATFORM_LOG_DIR", "PLATFORM_LOG_BODY_BACKEND", + "PLATFORM_BOOTSTRAP_ADMIN_EMAIL", + "PLATFORM_BOOTSTRAP_ADMIN_PASSWORD", + "PLATFORM_SECRET_ENVELOPE_KEY", } { t.Setenv(key, "") if err := os.Unsetenv(key); err != nil { diff --git a/platform/domain/client_manager_lifecycle.go b/platform/domain/client_manager_lifecycle.go new file mode 100644 index 0000000..055ce6f --- /dev/null +++ b/platform/domain/client_manager_lifecycle.go @@ -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 +} diff --git a/platform/domain/control.go b/platform/domain/control.go index 6973eb1..623f45c 100644 --- a/platform/domain/control.go +++ b/platform/domain/control.go @@ -19,6 +19,9 @@ type RunControlHello struct { Version string Status RunEndpointStatus Platform string + Architecture string + UpdateJobID string + UpdateOutcome string CapabilityReport RunCapabilityReport Capacity RunCapacity } @@ -29,6 +32,7 @@ type RunControlHelloResult struct { SessionToken string ServerTime time.Time HeartbeatIntervalSeconds int + SessionExpiresAt time.Time FeatureFlags []string } @@ -51,11 +55,29 @@ type RunControlHeartbeatResult struct { type RunControlSession struct { RunEndpointID string - SessionToken string + SessionToken string `json:"-"` + SessionTokenHash string + Status AuthSessionStatus + Generation int CapabilityFingerprint string HeartbeatIntervalSeconds int CreatedAt 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 { @@ -82,5 +104,6 @@ func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlH } func CopyRunControlSession(session RunControlSession) RunControlSession { + session.UsedNonces = CopyStringSlice(session.UsedNonces) return session } diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 77a4647..58bf0ff 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -18,8 +18,14 @@ type RunJobAssignment struct { State JobState Progress RunJobProgressReport ResultRef string + ExecutionInput JobExecutionInput LeaseToken string Attempt int + MaxAttempts int + AckDeadlineAt time.Time + LeaseExpiresAt time.Time + NextAttemptAt time.Time + ProgressSequence uint64 CreatedAt time.Time UpdatedAt time.Time } @@ -72,16 +78,18 @@ type RunJobProgressResult struct { } type RunJobResult struct { - RunEndpointID string - SessionToken string - JobID string - LeaseToken string - Attempt int - State JobState - Progress RunJobProgressReport - ResultRef string - Message string - ErrorCode string + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + State JobState + Progress RunJobProgressReport + ResultRef string + Message string + ErrorCode string + Retryable bool + ExecutionResult JobExecutionResult } type RunJobResultResult struct { @@ -107,6 +115,7 @@ type DistributionBuildInput struct { ProfileKey string TargetOS string TargetArch string + TargetRelease string PackageFormat string RepositoryURL string SourceRevision string @@ -117,6 +126,103 @@ type DistributionBuildInput struct { 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 { JobID string Reason string @@ -127,6 +233,8 @@ type RunJobCancelRequestResult struct { JobID string Reason string RequestedAt time.Time + CompletedAt time.Time + State JobState } type RunJobCancelPoll struct { @@ -134,6 +242,7 @@ type RunJobCancelPoll struct { SessionToken string JobID string LeaseToken string + Attempt int } type RunJobCancelPollResult struct { @@ -146,33 +255,26 @@ type RunJobCancelPollResult struct { ServerTime time.Time } +type RunJobReconcileEntry struct { + JobID string + LeaseToken string + Attempt int +} + type RunJobReconcile struct { RunEndpointID string SessionToken string - ActiveJobIDs []string + ActiveJobs []RunJobReconcileEntry } type RunJobReconcileResult struct { Accepted bool RunEndpointID string - ActiveJobs []RunJobAssignment - UnknownJobIDs []string + ConfirmedJobs []RunJobAssignment + DiscardJobIDs []string 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 { return assignment } @@ -196,13 +298,15 @@ func CopyRunJobClaimResult(result RunJobClaimResult) RunJobClaimResult { } func CopyRunJobReconcile(reconcile RunJobReconcile) RunJobReconcile { - reconcile.ActiveJobIDs = CopyStringSlice(reconcile.ActiveJobIDs) + if reconcile.ActiveJobs != nil { + reconcile.ActiveJobs = append([]RunJobReconcileEntry(nil), reconcile.ActiveJobs...) + } return reconcile } func CopyRunJobReconcileResult(result RunJobReconcileResult) RunJobReconcileResult { - result.ActiveJobs = CopyRunJobAssignments(result.ActiveJobs) - result.UnknownJobIDs = CopyStringSlice(result.UnknownJobIDs) + result.ConfirmedJobs = CopyRunJobAssignments(result.ConfirmedJobs) + result.DiscardJobIDs = CopyStringSlice(result.DiscardJobIDs) return result } @@ -215,6 +319,15 @@ func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment { return out } -func CopyRunJobLease(lease RunJobLease) RunJobLease { - return lease +func CopyDependencyExecutionInput(input DependencyExecutionInput) DependencyExecutionInput { + 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 } diff --git a/platform/domain/observability.go b/platform/domain/observability.go new file mode 100644 index 0000000..22761fe --- /dev/null +++ b/platform/domain/observability.go @@ -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 © +} + +func copyFloatPtr(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +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 } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index a976fb3..06ffa4d 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -81,6 +81,7 @@ const ( JobStateQueued JobState = "queued" JobStateAccepted JobState = "accepted" JobStateRunning JobState = "running" + JobStateRetrying JobState = "retrying" JobStateSucceeded JobState = "succeeded" JobStateFailed JobState = "failed" JobStateCancelled JobState = "cancelled" @@ -154,6 +155,19 @@ const ( 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 const ( @@ -227,6 +241,26 @@ type AuthSession struct { User User Status 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 { @@ -305,21 +339,126 @@ type GamePluginRemoteAccess struct { LogTransfer bool } -type GamePluginManifest struct { - ID string - Name string - Description string - Version string +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 - Tags []string - Server GamePluginManifestServer - Bridge GamePluginBridge + TargetKey string Capabilities []string - Permissions []string - Actions PluginLifecycleActions - Pages []GamePluginPage - AI GamePluginManifestAI - RemoteAccess GamePluginRemoteAccess +} + +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 { + ID string + Name string + Description string + Version string + Kind string + Tags []string + Server GamePluginManifestServer + Bridge GamePluginBridge + Capabilities []string + Permissions []string + Actions PluginLifecycleActions + Pages []GamePluginPage + AI GamePluginManifestAI + RemoteAccess GamePluginRemoteAccess + RuntimeProfiles GamePluginRuntimeProfiles } type GamePluginManifestRegistration struct { @@ -346,6 +485,7 @@ type GamePlugin struct { Tags []string AIPurposes []string RemoteAccess GamePluginRemoteAccess + RuntimeProfiles GamePluginRuntimeProfiles ValidationViolations []string Status GamePluginStatus } @@ -369,6 +509,7 @@ type PluginMarketplacePlugin struct { Tags []string AIPurposes []string RemoteAccess GamePluginRemoteAccess + RuntimeProfiles GamePluginRuntimeProfiles ValidationViolations []string Status GamePluginStatus Source string @@ -437,17 +578,21 @@ type PluginBridgeExecuteResponse struct { } type ServerInstance struct { - ID string - PluginID string - PluginVersion string - RunEndpointID string - Name string - OwnerUserID string - AdminUserIDs []string - State ServerInstanceState - ConfigVersion int - CreatedAt time.Time - UpdatedAt time.Time + ID string + PluginID string + PluginVersion string + RunEndpointID string + Name string + OwnerUserID string + AdminUserIDs []string + State ServerInstanceState + ConfigVersion int + ConfigKey string + ConfigContent string + ConfigChecksum string + ConfigUpdatedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } type ServerInstanceUpdate struct { @@ -482,6 +627,7 @@ type ServerConfig struct { Format string Key string Content string + Checksum string Source string UpdatedAt time.Time } @@ -496,6 +642,7 @@ type ConfigDiffLine struct { type ServerConfigDiffRequest struct { ServerInstanceID string ExpectedConfigVersion int + ExpectedChecksum string Key string ProposedContent string ProposedContentInputRef string @@ -504,6 +651,7 @@ type ServerConfigDiffRequest struct { type ServerConfigDiffPreview struct { ServerInstanceID string ConfigVersion int + Checksum string Key string CurrentContent string ProposedContent string @@ -517,6 +665,7 @@ type ServerConfigDiffPreview struct { type ServerConfigWriteApproval struct { ServerInstanceID string ExpectedConfigVersion int + ExpectedChecksum string Key string ProposedContent string ProposedContentInputRef string @@ -542,7 +691,9 @@ type FileOperationDispatchRequest struct { Operation FileOperationKind Key string InputRef string + Content string ExpectedConfigVersion int + ExpectedChecksum string IdempotencyKey string } @@ -590,6 +741,8 @@ type RunEndpoint struct { ID string DisplayName string Version string + Platform string + Architecture string Status RunEndpointStatus Capabilities []string Capacity RunCapacity @@ -601,19 +754,67 @@ type JobProgress struct { 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 { - ID string - ServerInstanceID string - RunEndpointID string - Capability string - TargetKey string - InputRef string - IdempotencyKey string - State JobState - Progress JobProgress - ResultRef string - CreatedAt time.Time - UpdatedAt time.Time + ID string + ServerInstanceID string + RunEndpointID string + Capability string + TargetKey string + InputRef string + IdempotencyKey string + State JobState + Progress JobProgress + 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 + UpdatedAt time.Time } type Artifact struct { @@ -631,6 +832,7 @@ type RuntimeBinding struct { ID string ServerInstanceID string PluginID string + PluginVersion string ProfileKey string Mode string Bindings map[string]string @@ -640,6 +842,32 @@ type RuntimeBinding struct { 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 { ID string ServerInstanceID string @@ -679,6 +907,7 @@ type ClientManagerDistribution struct { ServerInstanceID string PluginID string ProfileKey string + Version string TargetOS string TargetArch string RepositoryURL string @@ -703,6 +932,10 @@ type DependencyStatus struct { State DependencyState Required bool InstallPlanKey string + PlanDigest string + JobID string + Evidence string + CompletedSteps int Message string CheckedAt time.Time UpdatedAt time.Time @@ -713,6 +946,7 @@ type ClientManagerBuildJob struct { ServerInstanceID string PluginID string ProfileKey string + Version string TargetOS string TargetArch string RepositoryURL string @@ -732,9 +966,16 @@ type RunUpdateJob struct { RunEndpointID string ArtifactID string Checksum string + TargetOS string + TargetArch string + TargetRelease string + PreviousVersion string JobID string IdempotencyKey string Status DistributionJobStatus + Phase RunUpdatePhase + Message string + Rollback bool CreatedAt time.Time UpdatedAt time.Time } @@ -805,12 +1046,54 @@ type DependencyJobRequest struct { ServerInstanceID string ProbeKey string InstallPlanKey string + PlanDigest string TargetOS string TargetArch string IdempotencyKey string 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 { ServerInstanceID string SourceKey string @@ -846,6 +1129,12 @@ type UserFilter struct { Status UserStatus } +type AuthSessionFilter struct { + UserID string + TokenHash string + Status AuthSessionStatus +} + type AIProviderFilter struct { Kind AIProviderKind Status AIProviderStatus @@ -968,6 +1257,10 @@ func CopyUser(user User) User { return user } +func CopyAuthSessionRecord(session AuthSessionRecord) AuthSessionRecord { + return session +} + func CopyAIProvider(provider AIProvider) AIProvider { provider.Models = CopyStringSlice(provider.Models) return provider @@ -992,6 +1285,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin { plugin.Tags = CopyStringSlice(plugin.Tags) plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) + plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) return plugin } @@ -1005,6 +1299,7 @@ func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketpla plugin.Tags = CopyStringSlice(plugin.Tags) plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes) plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess) + plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles) plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations) return plugin } @@ -1034,9 +1329,48 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest { manifest.Pages = CopyGamePluginPageSlice(manifest.Pages) manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes) manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess) + manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles) 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 { remote.Methods = CopyStringSlice(remote.Methods) remote.RunCapabilities = CopyStringSlice(remote.RunCapabilities) @@ -1148,6 +1482,17 @@ func CopyRuntimeBinding(binding RuntimeBinding) RuntimeBinding { 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 { return key } @@ -1164,6 +1509,15 @@ func CopyDependencyStatus(status DependencyStatus) DependencyStatus { 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 { return job } diff --git a/platform/domain/resources.md b/platform/domain/resources.md index 570d76a..9f423e5 100644 --- a/platform/domain/resources.md +++ b/platform/domain/resources.md @@ -5,7 +5,7 @@ This file defines the first platform resource contracts. Concrete Go domain stru ## Implemented Boundaries - 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. - `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. @@ -27,7 +27,8 @@ This file defines the first platform resource contracts. Concrete Go domain stru - `name`: display name. - `kind`: `openai-compatible`, `openai`, `claude`, `gemini`, `ollama`, or `custom`. - `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. - `defaultModel`: optional default model. - `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 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 - `id`: runtime binding ID. - `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. - `mode`: runtime mode such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`. - `bindings`: logical binding keys to operator-provided settings. - `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 @@ -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. - `ClientManagerDistribution`: records a generated plugin-declared client-manager package with profile key, repository/source revision metadata, build job ID, artifact ID, checksum, key generation, secret ref, and status. - `ClientManagerBuildJob`: records source checkout/build status, target platform, artifact ID, checksum, redacted build log ref, key generation, and status. -- `RunUpdateJob`: records platform-created run self-update orchestration with server, 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. @@ -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. - `installPlanKey`: optional typed install plan key. - `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. 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. diff --git a/platform/domain/server_lifecycle.go b/platform/domain/server_lifecycle.go index cbb95e3..d70bdfb 100644 --- a/platform/domain/server_lifecycle.go +++ b/platform/domain/server_lifecycle.go @@ -6,12 +6,14 @@ const ( ServerLifecycleActionCreate ServerLifecycleAction = "create" ServerLifecycleActionStart ServerLifecycleAction = "start" ServerLifecycleActionStop ServerLifecycleAction = "stop" + ServerLifecycleActionStatus ServerLifecycleAction = "status" ) const ( LifecycleCapabilityInstall = "process.install" LifecycleCapabilityStart = "process.start" LifecycleCapabilityStop = "process.stop" + LifecycleCapabilityStatus = "process.status" ) type ServerLifecycleCreate struct { @@ -21,6 +23,8 @@ type ServerLifecycleCreate struct { Name string OwnerUserID string IdempotencyKey string + ProfileKey string + Bindings map[string]string } type ServerLifecycleCommand struct { @@ -44,12 +48,15 @@ func LifecycleCapabilityForAction(action ServerLifecycleAction) string { return LifecycleCapabilityStart case ServerLifecycleActionStop: return LifecycleCapabilityStop + case ServerLifecycleActionStatus: + return LifecycleCapabilityStatus default: return "" } } func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate { + create.Bindings = CopyStringMap(create.Bindings) return create } diff --git a/platform/dto/client_manager_lifecycle.go b/platform/dto/client_manager_lifecycle.go new file mode 100644 index 0000000..f9dea02 --- /dev/null +++ b/platform/dto/client_manager_lifecycle.go @@ -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)} +} diff --git a/platform/dto/control.go b/platform/dto/control.go index 389551c..3c31b09 100644 --- a/platform/dto/control.go +++ b/platform/dto/control.go @@ -23,6 +23,9 @@ type RunControlHelloRequest struct { Version string `json:"version"` Status domain.RunEndpointStatus `json:"status"` Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture,omitempty"` + UpdateJobID string `json:"updateJobId,omitempty"` + UpdateOutcome string `json:"updateOutcome,omitempty"` CapabilityReport RunCapabilityReport `json:"capabilityReport"` Capacity RunCapacityResponse `json:"capacity"` } @@ -33,6 +36,7 @@ type RunControlHelloResponse struct { SessionToken string `json:"sessionToken"` ServerTime time.Time `json:"serverTime"` HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"` + SessionExpiresAt time.Time `json:"sessionExpiresAt"` FeatureFlags []string `json:"featureFlags,omitempty"` } @@ -66,6 +70,9 @@ func (request RunControlHelloRequest) ToDomain() domain.RunControlHello { Version: request.Version, Status: request.Status, Platform: request.Platform, + Architecture: request.Architecture, + UpdateJobID: request.UpdateJobID, + UpdateOutcome: request.UpdateOutcome, CapabilityReport: domain.RunCapabilityReport{ Capabilities: domain.CopyStringSlice(request.CapabilityReport.Capabilities), Fingerprint: request.CapabilityReport.Fingerprint, @@ -93,6 +100,7 @@ func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHe SessionToken: result.SessionToken, ServerTime: result.ServerTime, HeartbeatIntervalSeconds: result.HeartbeatIntervalSeconds, + SessionExpiresAt: result.SessionExpiresAt, FeatureFlags: result.FeatureFlags, } } diff --git a/platform/dto/distributions.go b/platform/dto/distributions.go index ba59c25..baf4a34 100644 --- a/platform/dto/distributions.go +++ b/platform/dto/distributions.go @@ -12,6 +12,52 @@ type RunDistributionGenerateRequest struct { 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 { ArtifactID string `json:"artifactId"` Checksum string `json:"checksum,omitempty"` @@ -21,6 +67,7 @@ type RunUpdateRequest struct { type DependencyJobRequest struct { ProbeKey string `json:"probeKey"` InstallPlanKey string `json:"installPlanKey,omitempty"` + PlanDigest string `json:"planDigest,omitempty"` TargetOS string `json:"targetOs,omitempty"` TargetArch string `json:"targetArch,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"` @@ -103,6 +150,7 @@ type ClientManagerDistributionResponse struct { ServerInstanceID string `json:"serverInstanceId"` PluginID string `json:"pluginId"` ProfileKey string `json:"profileKey"` + Version string `json:"version,omitempty"` TargetOS string `json:"targetOs"` TargetArch string `json:"targetArch"` RepositoryURL string `json:"repositoryUrl"` @@ -127,16 +175,62 @@ type DependencyStatusResponse struct { State string `json:"state"` Required bool `json:"required"` 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"` CheckedAt time.Time `json:"checkedAt"` 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 { ID string `json:"id"` ServerInstanceID string `json:"serverInstanceId"` PluginID string `json:"pluginId"` ProfileKey string `json:"profileKey"` + Version string `json:"version,omitempty"` TargetOS string `json:"targetOs"` TargetArch string `json:"targetArch"` RepositoryURL string `json:"repositoryUrl"` @@ -156,13 +250,25 @@ type RunUpdateJobResponse struct { RunEndpointID string `json:"runEndpointId"` ArtifactID string `json:"artifactId"` 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"` IdempotencyKey string `json:"idempotencyKey,omitempty"` Status string `json:"status"` + Phase string `json:"phase"` + Message string `json:"message,omitempty"` + Rollback bool `json:"rollback"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } +type RunUpdateJobListResponse struct { + Items []RunUpdateJobResponse `json:"items"` + Count int `json:"count"` +} + func (request RunDistributionGenerateRequest) ToDomain(serverInstanceID string) domain.RunDistributionGenerateRequest { return domain.RunDistributionGenerateRequest{ ServerInstanceID: serverInstanceID, @@ -186,6 +292,7 @@ func (request DependencyJobRequest) ToDomain(serverInstanceID string, install bo ServerInstanceID: serverInstanceID, ProbeKey: request.ProbeKey, InstallPlanKey: request.InstallPlanKey, + PlanDigest: request.PlanDigest, TargetOS: request.TargetOS, TargetArch: request.TargetArch, IdempotencyKey: request.IdempotencyKey, @@ -280,6 +387,7 @@ func ClientManagerDistributionFromDomain(distribution domain.ClientManagerDistri ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, + Version: distribution.Version, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, RepositoryURL: distribution.RepositoryURL, @@ -306,18 +414,40 @@ func DependencyStatusFromDomain(status domain.DependencyStatus) DependencyStatus State: string(status.State), Required: status.Required, InstallPlanKey: status.InstallPlanKey, + PlanDigest: status.PlanDigest, + JobID: status.JobID, + Evidence: status.Evidence, + CompletedSteps: status.CompletedSteps, Message: status.Message, CheckedAt: status.CheckedAt, 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 { return ClientManagerBuildJobResponse{ ID: job.ID, ServerInstanceID: job.ServerInstanceID, PluginID: job.PluginID, ProfileKey: job.ProfileKey, + Version: job.Version, TargetOS: job.TargetOS, TargetArch: job.TargetArch, RepositoryURL: job.RepositoryURL, @@ -339,10 +469,25 @@ func RunUpdateJobFromDomain(job domain.RunUpdateJob) RunUpdateJobResponse { RunEndpointID: job.RunEndpointID, ArtifactID: job.ArtifactID, Checksum: job.Checksum, + TargetOS: job.TargetOS, + TargetArch: job.TargetArch, + TargetRelease: job.TargetRelease, + PreviousVersion: job.PreviousVersion, JobID: job.JobID, IdempotencyKey: job.IdempotencyKey, Status: string(job.Status), + Phase: string(job.Phase), + Message: job.Message, + Rollback: job.Rollback, CreatedAt: job.CreatedAt, 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)} +} diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index cf09e2f..336775a 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -7,20 +7,26 @@ import ( ) type RunJobAssignmentResponse struct { - JobID string `json:"jobId"` - ServerInstanceID string `json:"serverInstanceId,omitempty"` - RunEndpointID string `json:"runEndpointId"` - Capability string `json:"capability"` - TargetKey string `json:"targetKey,omitempty"` - InputRef string `json:"inputRef,omitempty"` - IdempotencyKey string `json:"idempotencyKey"` - State domain.JobState `json:"state"` - Progress JobProgressBody `json:"progress"` - ResultRef string `json:"resultRef,omitempty"` - LeaseToken string `json:"leaseToken"` - Attempt int `json:"attempt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + JobID string `json:"jobId"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + RunEndpointID string `json:"runEndpointId"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey,omitempty"` + InputRef string `json:"inputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + ExecutionInput RunJobExecutionInputBody `json:"executionInput,omitempty"` + LeaseToken string `json:"leaseToken"` + 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"` + UpdatedAt time.Time `json:"updatedAt"` } type RunJobClaimRequest struct { @@ -71,16 +77,41 @@ type RunJobProgressResponse struct { } type RunJobResultRequest struct { - RunEndpointID string `json:"runEndpointId"` - SessionToken string `json:"sessionToken"` - JobID string `json:"jobId"` - LeaseToken string `json:"leaseToken"` - Attempt int `json:"attempt"` - State domain.JobState `json:"state"` - Progress JobProgressBody `json:"progress"` - ResultRef string `json:"resultRef,omitempty"` - Message string `json:"message,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + Message string `json:"message,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 { @@ -106,6 +137,7 @@ type DistributionBuildInputResponse struct { ProfileKey string `json:"profileKey,omitempty"` TargetOS string `json:"targetOs"` TargetArch string `json:"targetArch"` + TargetRelease string `json:"targetRelease"` PackageFormat string `json:"packageFormat"` RepositoryURL string `json:"repositoryUrl,omitempty"` SourceRevision string `json:"sourceRevision,omitempty"` @@ -116,16 +148,101 @@ type DistributionBuildInputResponse struct { 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 { JobID string `json:"jobId"` Reason string `json:"reason"` } type RunJobCancelRequestResponse struct { - Accepted bool `json:"accepted"` - JobID string `json:"jobId"` - Reason string `json:"reason"` - RequestedAt time.Time `json:"requestedAt"` + Accepted bool `json:"accepted"` + JobID string `json:"jobId"` + Reason string `json:"reason"` + RequestedAt time.Time `json:"requestedAt"` + CompletedAt time.Time `json:"completedAt,omitempty"` + State domain.JobState `json:"state"` } type RunJobCancelPollRequest struct { @@ -133,6 +250,7 @@ type RunJobCancelPollRequest struct { SessionToken string `json:"sessionToken"` JobID string `json:"jobId,omitempty"` LeaseToken string `json:"leaseToken,omitempty"` + Attempt int `json:"attempt"` } type RunJobCancelPollResponse struct { @@ -145,17 +263,23 @@ type RunJobCancelPollResponse struct { ServerTime time.Time `json:"serverTime"` } +type RunJobReconcileEntry struct { + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` +} + type RunJobReconcileRequest struct { - RunEndpointID string `json:"runEndpointId"` - SessionToken string `json:"sessionToken"` - ActiveJobIDs []string `json:"activeJobIds"` + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ActiveJobs []RunJobReconcileEntry `json:"activeJobs"` } type RunJobReconcileResponse struct { Accepted bool `json:"accepted"` RunEndpointID string `json:"runEndpointId"` - ActiveJobs []RunJobAssignmentResponse `json:"activeJobs"` - UnknownJobIDs []string `json:"unknownJobIds"` + ConfirmedJobs []RunJobAssignmentResponse `json:"confirmedJobs"` + DiscardJobIDs []string `json:"discardJobIds"` ServerTime time.Time `json:"serverTime"` } @@ -193,16 +317,18 @@ func (request RunJobProgressRequest) ToDomain() domain.RunJobProgress { func (request RunJobResultRequest) ToDomain() domain.RunJobResult { return domain.RunJobResult{ - RunEndpointID: request.RunEndpointID, - SessionToken: request.SessionToken, - JobID: request.JobID, - LeaseToken: request.LeaseToken, - Attempt: request.Attempt, - State: request.State, - Progress: progressReportToDomain(request.Progress), - ResultRef: request.ResultRef, - Message: request.Message, - ErrorCode: request.ErrorCode, + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + JobID: request.JobID, + LeaseToken: request.LeaseToken, + Attempt: request.Attempt, + State: request.State, + Progress: progressReportToDomain(request.Progress), + ResultRef: request.ResultRef, + Message: request.Message, + 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 { return domain.RunJobCancelRequest{ JobID: request.JobID, @@ -229,14 +371,19 @@ func (request RunJobCancelPollRequest) ToDomain() domain.RunJobCancelPoll { SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, + Attempt: request.Attempt, } } 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{ RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, - ActiveJobIDs: domain.CopyStringSlice(request.ActiveJobIDs), + ActiveJobs: active, } } @@ -286,6 +433,7 @@ func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) Distr ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, + TargetRelease: input.TargetRelease, PackageFormat: input.PackageFormat, RepositoryURL: input.RepositoryURL, 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 { return RunJobCancelRequestResponse{ Accepted: result.Accepted, JobID: result.JobID, Reason: result.Reason, 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 { result = domain.CopyRunJobReconcileResult(result) - items := make([]RunJobAssignmentResponse, len(result.ActiveJobs)) - for i, assignment := range result.ActiveJobs { + items := make([]RunJobAssignmentResponse, len(result.ConfirmedJobs)) + for i, assignment := range result.ConfirmedJobs { items[i] = RunJobAssignmentFromDomain(assignment) } return RunJobReconcileResponse{ Accepted: result.Accepted, RunEndpointID: result.RunEndpointID, - ActiveJobs: items, - UnknownJobIDs: result.UnknownJobIDs, + ConfirmedJobs: items, + DiscardJobIDs: result.DiscardJobIDs, ServerTime: result.ServerTime, } } @@ -353,8 +525,14 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign State: assignment.State, Progress: progressReportFromDomain(assignment.Progress), ResultRef: assignment.ResultRef, + ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, + MaxAttempts: assignment.MaxAttempts, + AckDeadlineAt: assignment.AckDeadlineAt, + LeaseExpiresAt: assignment.LeaseExpiresAt, + NextAttemptAt: assignment.NextAttemptAt, + ProgressSequence: assignment.ProgressSequence, CreatedAt: assignment.CreatedAt, UpdatedAt: assignment.UpdatedAt, } diff --git a/platform/dto/observability.go b/platform/dto/observability.go new file mode 100644 index 0000000..2f43202 --- /dev/null +++ b/platform/dto/observability.go @@ -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} +} diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 5506bb6..15f9e34 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -93,6 +93,7 @@ type AuthSessionResponse struct { SessionID string `json:"sessionId,omitempty"` Status string `json:"status"` Message string `json:"message,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } type AIProviderCreateRequest struct { @@ -125,17 +126,17 @@ type AIProviderStatusRequest struct { } type AIProviderResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Kind domain.AIProviderKind `json:"kind"` - BaseURL string `json:"baseUrl"` - APIKeyRef string `json:"apiKeyRef"` - Models []string `json:"models"` - DefaultModel string `json:"defaultModel,omitempty"` - RelayMode domain.AIRelayMode `json:"relayMode"` - TimeoutMS int `json:"timeoutMs"` - Status domain.AIProviderStatus `json:"status"` - RedactionPolicy string `json:"redactionPolicy"` + ID string `json:"id"` + Name string `json:"name"` + Kind domain.AIProviderKind `json:"kind"` + BaseURL string `json:"baseUrl"` + APIKeyConfigured bool `json:"apiKeyConfigured"` + Models []string `json:"models"` + DefaultModel string `json:"defaultModel,omitempty"` + RelayMode domain.AIRelayMode `json:"relayMode"` + TimeoutMS int `json:"timeoutMs"` + Status domain.AIProviderStatus `json:"status"` + RedactionPolicy string `json:"redactionPolicy"` } type AIProviderListResponse struct { @@ -206,20 +207,21 @@ type GamePluginRemoteAccessBody struct { } type GamePluginManifestBody struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version"` - Kind string `json:"kind"` - Tags []string `json:"tags,omitempty"` - Server GamePluginManifestServerBody `json:"server"` - Bridge GamePluginBridgeBody `json:"bridge,omitempty"` - Capabilities []string `json:"capabilities"` - Permissions []string `json:"permissions"` - Actions PluginLifecycleActionsBody `json:"actions"` - Pages []GamePluginPageBody `json:"pages,omitempty"` - AI GamePluginManifestAIBody `json:"ai,omitempty"` - RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Kind string `json:"kind"` + Tags []string `json:"tags,omitempty"` + Server GamePluginManifestServerBody `json:"server"` + Bridge GamePluginBridgeBody `json:"bridge,omitempty"` + Capabilities []string `json:"capabilities"` + Permissions []string `json:"permissions"` + Actions PluginLifecycleActionsBody `json:"actions"` + Pages []GamePluginPageBody `json:"pages,omitempty"` + AI GamePluginManifestAIBody `json:"ai,omitempty"` + RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` + RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` } type GamePluginManifestRegistrationRequest struct { @@ -228,48 +230,50 @@ type GamePluginManifestRegistrationRequest struct { } type GamePluginCreateRequest struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version"` - ServerType string `json:"serverType"` - ServerDisplayName string `json:"serverDisplayName,omitempty"` - SupportedOS []string `json:"supportedOs,omitempty"` - ManifestRef string `json:"manifestRef"` - CreateFormSchemaRef string `json:"createFormSchemaRef"` - RequiredRunCapabilities []string `json:"requiredRunCapabilities"` - DeclaredPermissions []string `json:"declaredPermissions,omitempty"` - Permissions PluginPermissionsResponse `json:"permissions"` - LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"` - BridgeActions []string `json:"bridgeActions,omitempty"` - Pages []GamePluginPageBody `json:"pages,omitempty"` - Tags []string `json:"tags,omitempty"` - AIPurposes []string `json:"aiPurposes,omitempty"` - RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` - ValidationViolations []string `json:"validationViolations,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + RequiredRunCapabilities []string `json:"requiredRunCapabilities"` + DeclaredPermissions []string `json:"declaredPermissions,omitempty"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"` + BridgeActions []string `json:"bridgeActions,omitempty"` + Pages []GamePluginPageBody `json:"pages,omitempty"` + Tags []string `json:"tags,omitempty"` + AIPurposes []string `json:"aiPurposes,omitempty"` + RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` + RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` + ValidationViolations []string `json:"validationViolations,omitempty"` } type GamePluginResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version"` - ServerType string `json:"serverType"` - ServerDisplayName string `json:"serverDisplayName,omitempty"` - SupportedOS []string `json:"supportedOs,omitempty"` - ManifestRef string `json:"manifestRef"` - CreateFormSchemaRef string `json:"createFormSchemaRef"` - RequiredRunCapabilities []string `json:"requiredRunCapabilities"` - DeclaredPermissions []string `json:"declaredPermissions"` - Permissions PluginPermissionsResponse `json:"permissions"` - LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` - BridgeActions []string `json:"bridgeActions"` - Pages []GamePluginPageBody `json:"pages"` - Tags []string `json:"tags"` - AIPurposes []string `json:"aiPurposes"` - RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` - ValidationViolations []string `json:"validationViolations,omitempty"` - Status domain.GamePluginStatus `json:"status"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + RequiredRunCapabilities []string `json:"requiredRunCapabilities"` + DeclaredPermissions []string `json:"declaredPermissions"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` + BridgeActions []string `json:"bridgeActions"` + Pages []GamePluginPageBody `json:"pages"` + Tags []string `json:"tags"` + AIPurposes []string `json:"aiPurposes"` + RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` + RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` + ValidationViolations []string `json:"validationViolations,omitempty"` + Status domain.GamePluginStatus `json:"status"` } type GamePluginListResponse struct { @@ -278,27 +282,28 @@ type GamePluginListResponse struct { } type MarketplacePluginResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version"` - ServerType string `json:"serverType"` - ServerDisplayName string `json:"serverDisplayName,omitempty"` - SupportedOS []string `json:"supportedOs,omitempty"` - ManifestRef string `json:"manifestRef"` - CreateFormSchemaRef string `json:"createFormSchemaRef"` - Capabilities []string `json:"capabilities"` - DeclaredPermissions []string `json:"declaredPermissions"` - Permissions PluginPermissionsResponse `json:"permissions"` - LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` - BridgeActions []string `json:"bridgeActions"` - Pages []GamePluginPageBody `json:"pages"` - Tags []string `json:"tags"` - AIPurposes []string `json:"aiPurposes"` - RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` - ValidationViolations []string `json:"validationViolations,omitempty"` - Status domain.GamePluginStatus `json:"status"` - Source string `json:"source"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + ServerType string `json:"serverType"` + ServerDisplayName string `json:"serverDisplayName,omitempty"` + SupportedOS []string `json:"supportedOs,omitempty"` + ManifestRef string `json:"manifestRef"` + CreateFormSchemaRef string `json:"createFormSchemaRef"` + Capabilities []string `json:"capabilities"` + DeclaredPermissions []string `json:"declaredPermissions"` + Permissions PluginPermissionsResponse `json:"permissions"` + LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"` + BridgeActions []string `json:"bridgeActions"` + Pages []GamePluginPageBody `json:"pages"` + Tags []string `json:"tags"` + AIPurposes []string `json:"aiPurposes"` + RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"` + RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"` + ValidationViolations []string `json:"validationViolations,omitempty"` + Status domain.GamePluginStatus `json:"status"` + Source string `json:"source"` } type MarketplacePluginListResponse struct { @@ -389,17 +394,20 @@ type ServerMemberListResponse struct { } type ServerInstanceResponse struct { - ID string `json:"id"` - PluginID string `json:"pluginId"` - PluginVersion string `json:"pluginVersion"` - RunEndpointID string `json:"runEndpointId"` - Name string `json:"name"` - OwnerUserID string `json:"ownerUserId,omitempty"` - AdminUserIDs []string `json:"adminUserIds"` - State domain.ServerInstanceState `json:"state"` - ConfigVersion int `json:"configVersion"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + PluginID string `json:"pluginId"` + PluginVersion string `json:"pluginVersion"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + AdminUserIDs []string `json:"adminUserIds"` + State domain.ServerInstanceState `json:"state"` + 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"` + UpdatedAt time.Time `json:"updatedAt"` } type ServerInstanceListResponse struct { @@ -440,6 +448,7 @@ type ServerConfigResponse struct { Format string `json:"format"` Key string `json:"key,omitempty"` Content string `json:"content"` + Checksum string `json:"checksum"` Source string `json:"source,omitempty"` UpdatedAt time.Time `json:"updatedAt"` } @@ -453,6 +462,7 @@ type ConfigDiffLineResponse struct { type ServerConfigDiffPreviewRequest struct { ExpectedConfigVersion int `json:"expectedConfigVersion"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` Key string `json:"key"` ProposedContent string `json:"proposedContent,omitempty"` ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` @@ -461,6 +471,7 @@ type ServerConfigDiffPreviewRequest struct { type ServerConfigDiffPreviewResponse struct { ServerInstanceID string `json:"serverInstanceId"` ConfigVersion int `json:"configVersion"` + Checksum string `json:"checksum"` Key string `json:"key"` CurrentContent string `json:"currentContent"` ProposedContent string `json:"proposedContent,omitempty"` @@ -473,6 +484,7 @@ type ServerConfigDiffPreviewResponse struct { type ServerConfigWriteApprovalRequest struct { ExpectedConfigVersion int `json:"expectedConfigVersion"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` Key string `json:"key"` ProposedContent string `json:"proposedContent,omitempty"` ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"` @@ -491,7 +503,9 @@ type FileOperationDispatchRequest struct { Operation domain.FileOperationKind `json:"operation"` Key string `json:"key"` InputRef string `json:"inputRef,omitempty"` + Content string `json:"content,omitempty"` ExpectedConfigVersion int `json:"expectedConfigVersion,omitempty"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` IdempotencyKey string `json:"idempotencyKey"` } @@ -516,6 +530,8 @@ type RunEndpointCreateRequest struct { ID string `json:"id"` DisplayName string `json:"displayName"` Version string `json:"version"` + Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture,omitempty"` Status domain.RunEndpointStatus `json:"status"` Capabilities []string `json:"capabilities"` Capacity RunCapacityResponse `json:"capacity"` @@ -526,6 +542,8 @@ type RunEndpointResponse struct { ID string `json:"id"` DisplayName string `json:"displayName"` Version string `json:"version"` + Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture,omitempty"` Status domain.RunEndpointStatus `json:"status"` Capabilities []string `json:"capabilities"` Capacity RunCapacityResponse `json:"capacity"` @@ -553,19 +571,49 @@ type JobProgressBody struct { Message string `json:"message,omitempty"` } +type JobRetryPolicyResponse struct { + MaxAttempts int `json:"maxAttempts"` + InitialBackoffSeconds int `json:"initialBackoffSeconds"` + MaxBackoffSeconds int `json:"maxBackoffSeconds"` +} + type JobResponse struct { - ID string `json:"id"` - ServerInstanceID string `json:"serverInstanceId,omitempty"` - RunEndpointID string `json:"runEndpointId"` - Capability string `json:"capability"` - TargetKey string `json:"targetKey,omitempty"` - InputRef string `json:"inputRef,omitempty"` - IdempotencyKey string `json:"idempotencyKey"` - State domain.JobState `json:"state"` - Progress JobProgressBody `json:"progress"` - ResultRef string `json:"resultRef,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId,omitempty"` + RunEndpointID string `json:"runEndpointId"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey,omitempty"` + InputRef string `json:"inputRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + ResultRef string `json:"resultRef,omitempty"` + ExecutionResult JobExecutionResultResponse `json:"executionResult,omitempty"` + RetryPolicy JobRetryPolicyResponse `json:"retryPolicy"` + Attempt int `json:"attempt"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + AckDeadlineAt *time.Time `json:"ackDeadlineAt,omitempty"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + CancelReason string `json:"cancelReason,omitempty"` + CancelRequestedAt *time.Time `json:"cancelRequestedAt,omitempty"` + CancelCompletedAt *time.Time `json:"cancelCompletedAt,omitempty"` + TerminalAt *time.Time `json:"terminalAt,omitempty"` + LastReconciledAt *time.Time `json:"lastReconciledAt,omitempty"` + ReconcileCount int `json:"reconcileCount"` + ReconcileOutcome string `json:"reconcileOutcome,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type JobExecutionResultResponse struct { + 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 { @@ -768,20 +816,21 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi return domain.GamePluginManifestRegistration{ ManifestRef: request.ManifestRef, Manifest: domain.GamePluginManifest{ - ID: request.Manifest.ID, - Name: request.Manifest.Name, - Description: request.Manifest.Description, - Version: request.Manifest.Version, - Kind: request.Manifest.Kind, - Tags: domain.CopyStringSlice(request.Manifest.Tags), - Server: request.Manifest.Server.ToDomain(), - Bridge: request.Manifest.Bridge.ToDomain(), - Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities), - Permissions: domain.CopyStringSlice(request.Manifest.Permissions), - Actions: request.Manifest.Actions.ToDomain(), - Pages: pagesToDomain(request.Manifest.Pages), - AI: request.Manifest.AI.ToDomain(), - RemoteAccess: request.Manifest.RemoteAccess.ToDomain(), + ID: request.Manifest.ID, + Name: request.Manifest.Name, + Description: request.Manifest.Description, + Version: request.Manifest.Version, + Kind: request.Manifest.Kind, + Tags: domain.CopyStringSlice(request.Manifest.Tags), + Server: request.Manifest.Server.ToDomain(), + Bridge: request.Manifest.Bridge.ToDomain(), + Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities), + Permissions: domain.CopyStringSlice(request.Manifest.Permissions), + Actions: request.Manifest.Actions.ToDomain(), + Pages: pagesToDomain(request.Manifest.Pages), + AI: request.Manifest.AI.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), AIPurposes: domain.CopyStringSlice(request.AIPurposes), RemoteAccess: request.RemoteAccess.ToDomain(), + RuntimeProfiles: request.RuntimeProfiles.ToDomain(), ValidationViolations: domain.CopyStringSlice(request.ValidationViolations), } } @@ -867,6 +917,7 @@ func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string) return domain.ServerConfigDiffRequest{ ServerInstanceID: serverInstanceID, ExpectedConfigVersion: request.ExpectedConfigVersion, + ExpectedChecksum: request.ExpectedChecksum, Key: request.Key, ProposedContent: request.ProposedContent, ProposedContentInputRef: request.ProposedContentInputRef, @@ -877,6 +928,7 @@ func (request ServerConfigWriteApprovalRequest) ToDomain(serverInstanceID string return domain.ServerConfigWriteApproval{ ServerInstanceID: serverInstanceID, ExpectedConfigVersion: request.ExpectedConfigVersion, + ExpectedChecksum: request.ExpectedChecksum, Key: request.Key, ProposedContent: request.ProposedContent, ProposedContentInputRef: request.ProposedContentInputRef, @@ -891,7 +943,9 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa Operation: request.Operation, Key: request.Key, InputRef: request.InputRef, + Content: request.Content, ExpectedConfigVersion: request.ExpectedConfigVersion, + ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey, } } @@ -901,6 +955,8 @@ func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint { ID: request.ID, DisplayName: request.DisplayName, Version: request.Version, + Platform: request.Platform, + Architecture: request.Architecture, Status: request.Status, Capabilities: domain.CopyStringSlice(request.Capabilities), Capacity: capacityToDomain(request.Capacity), @@ -988,6 +1044,7 @@ func AuthSessionFromDomain(session domain.AuthSession) AuthSessionResponse { SessionID: session.SessionID, Status: session.Status, Message: session.Message, + ExpiresAt: session.ExpiresAt, } } @@ -1016,17 +1073,17 @@ func UserListFromDomain(users []domain.User) UserListResponse { func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse { provider = domain.CopyAIProvider(provider) return AIProviderResponse{ - ID: provider.ID, - Name: provider.Name, - Kind: provider.Kind, - BaseURL: provider.BaseURL, - APIKeyRef: provider.APIKeyRef, - Models: provider.Models, - DefaultModel: provider.DefaultModel, - RelayMode: provider.RelayMode, - TimeoutMS: provider.TimeoutMS, - Status: provider.Status, - RedactionPolicy: provider.RedactionPolicy, + ID: provider.ID, + Name: provider.Name, + Kind: provider.Kind, + BaseURL: provider.BaseURL, + APIKeyConfigured: provider.APIKeyRef != "", + Models: provider.Models, + DefaultModel: provider.DefaultModel, + RelayMode: provider.RelayMode, + TimeoutMS: provider.TimeoutMS, + Status: provider.Status, + RedactionPolicy: provider.RedactionPolicy, } } @@ -1079,6 +1136,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse { Tags: plugin.Tags, AIPurposes: plugin.AIPurposes, RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), + RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, } @@ -1167,6 +1225,7 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl Tags: plugin.Tags, AIPurposes: plugin.AIPurposes, RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), + RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles), ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, Source: plugin.Source, @@ -1188,17 +1247,20 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp adminUserIDs = []string{} } return ServerInstanceResponse{ - ID: instance.ID, - PluginID: instance.PluginID, - PluginVersion: instance.PluginVersion, - RunEndpointID: instance.RunEndpointID, - Name: instance.Name, - OwnerUserID: instance.OwnerUserID, - AdminUserIDs: adminUserIDs, - State: instance.State, - ConfigVersion: instance.ConfigVersion, - CreatedAt: instance.CreatedAt, - UpdatedAt: instance.UpdatedAt, + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + OwnerUserID: instance.OwnerUserID, + AdminUserIDs: adminUserIDs, + State: instance.State, + ConfigVersion: instance.ConfigVersion, + ConfigKey: instance.ConfigKey, + ConfigChecksum: instance.ConfigChecksum, + ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt), + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, } } @@ -1274,6 +1336,7 @@ func ServerConfigFromDomain(config domain.ServerConfig) ServerConfigResponse { Format: config.Format, Key: config.Key, Content: config.Content, + Checksum: config.Checksum, Source: config.Source, UpdatedAt: config.UpdatedAt, } @@ -1293,6 +1356,7 @@ func ServerConfigDiffPreviewFromDomain(preview domain.ServerConfigDiffPreview) S return ServerConfigDiffPreviewResponse{ ServerInstanceID: preview.ServerInstanceID, ConfigVersion: preview.ConfigVersion, + Checksum: preview.Checksum, Key: preview.Key, CurrentContent: preview.CurrentContent, ProposedContent: preview.ProposedContent, @@ -1332,6 +1396,8 @@ func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { ID: endpoint.ID, DisplayName: endpoint.DisplayName, Version: endpoint.Version, + Platform: endpoint.Platform, + Architecture: endpoint.Architecture, Status: endpoint.Status, Capabilities: endpoint.Capabilities, Capacity: capacityFromDomain(endpoint.Capacity), @@ -1359,11 +1425,36 @@ func JobFromDomain(job domain.Job) JobResponse { State: job.State, Progress: progressFromDomain(job.Progress), ResultRef: job.ResultRef, - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, + 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, + UpdatedAt: job.UpdatedAt, } } +func optionalTime(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + copy := value + return © +} + func JobListFromDomain(jobs []domain.Job) JobListResponse { items := make([]JobResponse, len(jobs)) for i, job := range jobs { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index b2dc46b..163b286 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -7,7 +7,7 @@ import ( "browser.local/platform/domain" ) -func TestAIProviderResponseExposesOnlyKeyReference(t *testing.T) { +func TestAIProviderResponseExposesOnlyKeyPresence(t *testing.T) { responseType := reflect.TypeOf(AIProviderResponse{}) if _, ok := responseType.FieldByName("APIKey"); ok { 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 { t.Fatal("AI provider response must not expose raw API key") } - if _, ok := responseType.FieldByName("APIKeyRef"); !ok { - t.Fatal("AI provider response must expose API key reference") + if _, ok := responseType.FieldByName("APIKeyRef"); ok { + 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" { t.Fatalf("expected response models to be copied, got source models %+v", provider.Models) } - if response.APIKeyRef != provider.APIKeyRef { - t.Fatalf("expected API key reference to be preserved, got %q", response.APIKeyRef) + if !response.APIKeyConfigured { + t.Fatal("expected configured API key presence") } } diff --git a/platform/dto/runtime_profiles.go b/platform/dto/runtime_profiles.go new file mode 100644 index 0000000..8f27c45 --- /dev/null +++ b/platform/dto/runtime_profiles.go @@ -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 +} diff --git a/platform/dto/server_lifecycle.go b/platform/dto/server_lifecycle.go index 041afd5..ed8620d 100644 --- a/platform/dto/server_lifecycle.go +++ b/platform/dto/server_lifecycle.go @@ -3,12 +3,14 @@ package dto import "browser.local/platform/domain" type ServerLifecycleCreateRequest struct { - ID string `json:"id"` - PluginID string `json:"pluginId"` - RunEndpointID string `json:"runEndpointId"` - Name string `json:"name"` - OwnerUserID string `json:"ownerUserId,omitempty"` - IdempotencyKey string `json:"idempotencyKey"` + ID string `json:"id"` + PluginID string `json:"pluginId"` + RunEndpointID string `json:"runEndpointId"` + Name string `json:"name"` + OwnerUserID string `json:"ownerUserId,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` + ProfileKey string `json:"profileKey"` + Bindings map[string]string `json:"bindings,omitempty"` } type ServerLifecycleCommandRequest struct { @@ -31,6 +33,8 @@ func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCre Name: request.Name, OwnerUserID: request.OwnerUserID, IdempotencyKey: request.IdempotencyKey, + ProfileKey: request.ProfileKey, + Bindings: domain.CopyStringMap(request.Bindings), } } diff --git a/platform/model/client_manager_lifecycle.go b/platform/model/client_manager_lifecycle.go new file mode 100644 index 0000000..73f927c --- /dev/null +++ b/platform/model/client_manager_lifecycle.go @@ -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) +} diff --git a/platform/model/distributions.go b/platform/model/distributions.go index 78a6015..c021639 100644 --- a/platform/model/distributions.go +++ b/platform/model/distributions.go @@ -10,6 +10,7 @@ type RuntimeBinding struct { ID string `json:"id" db:"id"` ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` PluginID string `json:"pluginId" db:"plugin_id"` + PluginVersion string `json:"pluginVersion" db:"plugin_version"` ProfileKey string `json:"profileKey" db:"profile_key"` Mode string `json:"mode" db:"mode"` Bindings map[string]string `json:"bindings" db:"bindings"` @@ -64,6 +65,7 @@ type ClientManagerDistribution struct { ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` PluginID string `json:"pluginId" db:"plugin_id"` ProfileKey string `json:"profileKey" db:"profile_key"` + Version string `json:"version" db:"version"` TargetOS string `json:"targetOs" db:"target_os"` TargetArch string `json:"targetArch" db:"target_arch"` RepositoryURL string `json:"repositoryUrl" db:"repository_url"` @@ -90,6 +92,10 @@ type DependencyStatus struct { State domain.DependencyState `json:"state" db:"state"` Required bool `json:"required" db:"required"` 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"` CheckedAt time.Time `json:"checkedAt" db:"checked_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` @@ -102,6 +108,7 @@ type ClientManagerBuildJob struct { ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` PluginID string `json:"pluginId" db:"plugin_id"` ProfileKey string `json:"profileKey" db:"profile_key"` + Version string `json:"version" db:"version"` TargetOS string `json:"targetOs" db:"target_os"` TargetArch string `json:"targetArch" db:"target_arch"` RepositoryURL string `json:"repositoryUrl" db:"repository_url"` @@ -123,9 +130,16 @@ type RunUpdateJob struct { RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"` ArtifactID string `json:"artifactId" db:"artifact_id"` 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"` IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"` 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"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } @@ -138,6 +152,7 @@ func RuntimeBindingFromDomain(binding domain.RuntimeBinding) RuntimeBinding { ID: binding.ID, ServerInstanceID: binding.ServerInstanceID, PluginID: binding.PluginID, + PluginVersion: binding.PluginVersion, ProfileKey: binding.ProfileKey, Mode: binding.Mode, Bindings: binding.Bindings, @@ -153,6 +168,7 @@ func (binding RuntimeBinding) ToDomain() domain.RuntimeBinding { ID: binding.ID, ServerInstanceID: binding.ServerInstanceID, PluginID: binding.PluginID, + PluginVersion: binding.PluginVersion, ProfileKey: binding.ProfileKey, Mode: binding.Mode, Bindings: domain.CopyStringMap(binding.Bindings), diff --git a/platform/model/observability.go b/platform/model/observability.go new file mode 100644 index 0000000..4f393ee --- /dev/null +++ b/platform/model/observability.go @@ -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} +} diff --git a/platform/model/resources.go b/platform/model/resources.go index 46f15ab..4ef0bc6 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -31,6 +31,44 @@ type User struct { 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 { // ID is the stable AI provider identifier. ID string `json:"id" db:"id"` @@ -145,6 +183,8 @@ type GamePlugin struct { AIPurposes []string `json:"aiPurposes" db:"ai_purposes"` // RemoteAccess stores plugin-declared remote access metadata. 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 []string `json:"validationViolations" db:"validation_violations"` // Status is the plugin lifecycle status. @@ -172,6 +212,14 @@ type ServerInstance struct { State domain.ServerInstanceState `json:"state" db:"state"` // ConfigVersion is the platform-managed optimistic concurrency 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 time.Time `json:"createdAt" db:"created_at"` // UpdatedAt is the last update timestamp. @@ -198,6 +246,10 @@ type RunEndpoint struct { DisplayName string `json:"displayName" db:"display_name"` // Version is the run binary 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 domain.RunEndpointStatus `json:"status" db:"status"` // Capabilities lists advertised run capability keys. @@ -217,6 +269,38 @@ type JobProgress struct { 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 { // ID is the stable job identifier. ID string `json:"id" db:"id"` @@ -238,6 +322,39 @@ type Job struct { Progress JobProgress `json:"progress" db:"progress"` // ResultRef references the terminal result artifact or summary. 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 time.Time `json:"createdAt" db:"created_at"` // UpdatedAt is the last update timestamp. @@ -395,6 +512,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin { Tags: plugin.Tags, AIPurposes: plugin.AIPurposes, RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess), + RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles), ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, } @@ -419,6 +537,7 @@ func (plugin GamePlugin) ToDomain() domain.GamePlugin { Tags: domain.CopyStringSlice(plugin.Tags), AIPurposes: domain.CopyStringSlice(plugin.AIPurposes), RemoteAccess: plugin.RemoteAccess.ToDomain(), + RuntimeProfiles: domain.CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles), ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations), Status: plugin.Status, } @@ -521,29 +640,41 @@ func remoteAccessFromDomain(remote domain.GamePluginRemoteAccess) GamePluginRemo func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstance { return ServerInstance{ - ID: instance.ID, - PluginID: instance.PluginID, - PluginVersion: instance.PluginVersion, - RunEndpointID: instance.RunEndpointID, - Name: instance.Name, - State: instance.State, - ConfigVersion: instance.ConfigVersion, - CreatedAt: instance.CreatedAt, - UpdatedAt: instance.UpdatedAt, + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + OwnerUserID: instance.OwnerUserID, + AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs), + State: instance.State, + ConfigVersion: instance.ConfigVersion, + ConfigKey: instance.ConfigKey, + ConfigContent: instance.ConfigContent, + ConfigChecksum: instance.ConfigChecksum, + ConfigUpdatedAt: instance.ConfigUpdatedAt, + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, } } func (instance ServerInstance) ToDomain() domain.ServerInstance { return domain.ServerInstance{ - ID: instance.ID, - PluginID: instance.PluginID, - PluginVersion: instance.PluginVersion, - RunEndpointID: instance.RunEndpointID, - Name: instance.Name, - State: instance.State, - ConfigVersion: instance.ConfigVersion, - CreatedAt: instance.CreatedAt, - UpdatedAt: instance.UpdatedAt, + ID: instance.ID, + PluginID: instance.PluginID, + PluginVersion: instance.PluginVersion, + RunEndpointID: instance.RunEndpointID, + Name: instance.Name, + OwnerUserID: instance.OwnerUserID, + AdminUserIDs: domain.CopyStringSlice(instance.AdminUserIDs), + State: instance.State, + ConfigVersion: instance.ConfigVersion, + ConfigKey: instance.ConfigKey, + ConfigContent: instance.ConfigContent, + ConfigChecksum: instance.ConfigChecksum, + ConfigUpdatedAt: instance.ConfigUpdatedAt, + CreatedAt: instance.CreatedAt, + UpdatedAt: instance.UpdatedAt, } } @@ -553,6 +684,8 @@ func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpoint { ID: endpoint.ID, DisplayName: endpoint.DisplayName, Version: endpoint.Version, + Platform: endpoint.Platform, + Architecture: endpoint.Architecture, Status: endpoint.Status, Capabilities: endpoint.Capabilities, Capacity: capacityFromDomain(endpoint.Capacity), @@ -565,6 +698,8 @@ func (endpoint RunEndpoint) ToDomain() domain.RunEndpoint { ID: endpoint.ID, DisplayName: endpoint.DisplayName, Version: endpoint.Version, + Platform: endpoint.Platform, + Architecture: endpoint.Architecture, Status: endpoint.Status, Capabilities: domain.CopyStringSlice(endpoint.Capabilities), Capacity: endpoint.Capacity.ToDomain(), @@ -592,35 +727,105 @@ func capacityFromDomain(capacity domain.RunCapacity) RunCapacity { func JobFromDomain(job domain.Job) Job { return Job{ - ID: job.ID, - ServerInstanceID: job.ServerInstanceID, - RunEndpointID: job.RunEndpointID, - Capability: job.Capability, - TargetKey: job.TargetKey, - InputRef: job.InputRef, - IdempotencyKey: job.IdempotencyKey, - State: job.State, - Progress: progressFromDomain(job.Progress), - ResultRef: job.ResultRef, - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, + ID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: progressFromDomain(job.Progress), + 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, + UpdatedAt: job.UpdatedAt, } } func (job Job) ToDomain() domain.Job { return domain.Job{ - ID: job.ID, - ServerInstanceID: job.ServerInstanceID, - RunEndpointID: job.RunEndpointID, - Capability: job.Capability, - TargetKey: job.TargetKey, - InputRef: job.InputRef, - IdempotencyKey: job.IdempotencyKey, - State: job.State, - Progress: job.Progress.ToDomain(), - ResultRef: job.ResultRef, - CreatedAt: job.CreatedAt, - UpdatedAt: job.UpdatedAt, + ID: job.ID, + ServerInstanceID: job.ServerInstanceID, + RunEndpointID: job.RunEndpointID, + Capability: job.Capability, + TargetKey: job.TargetKey, + InputRef: job.InputRef, + IdempotencyKey: job.IdempotencyKey, + State: job.State, + Progress: job.Progress.ToDomain(), + 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, + 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, } } diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index cccaed1..dcfabbb 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -8,15 +8,18 @@ import ( func TestTableNames(t *testing.T) { tests := map[string]string{ - User{}.TableName(): "users", - AIProvider{}.TableName(): "ai_providers", - GamePlugin{}.TableName(): "game_plugins", - ServerInstance{}.TableName(): "server_instances", - RunEndpoint{}.TableName(): "run_endpoints", - Job{}.TableName(): "jobs", - Artifact{}.TableName(): "artifacts", - LogStream{}.TableName(): "log_streams", - AuditEvent{}.TableName(): "audit_events", + User{}.TableName(): "users", + AIProvider{}.TableName(): "ai_providers", + GamePlugin{}.TableName(): "game_plugins", + ServerInstance{}.TableName(): "server_instances", + RunEndpoint{}.TableName(): "run_endpoints", + Job{}.TableName(): "jobs", + Artifact{}.TableName(): "artifacts", + LogStream{}.TableName(): "log_streams", + 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 { @@ -40,8 +43,9 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { Pages: []domain.GamePluginPage{ {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, }, - Tags: []string{"survival"}, - AIPurposes: []string{"logs.diagnose"}, + Tags: []string{"survival"}, + AIPurposes: []string{"logs.diagnose"}, + RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}}, Permissions: domain.PluginPermissions{ Jobs: true, Logs: true, @@ -57,6 +61,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { roundTrip.Pages[0].Permissions[0] = "ai.invoke" roundTrip.Tags[0] = "mutated" roundTrip.AIPurposes[0] = "config.suggest" + roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop" if source.RequiredRunCapabilities[0] != "process.start" { 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" { 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) { diff --git a/platform/protocol/ai-provider-contracts.md b/platform/protocol/ai-provider-contracts.md index 9cc66cd..5c44abf 100644 --- a/platform/protocol/ai-provider-contracts.md +++ b/platform/protocol/ai-provider-contracts.md @@ -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. - `AIProviderUpdateRequest`: replace editable provider metadata while preserving status through the service layer. - `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. - `AIProviderModelsResponse`: configured model list and default model, without credentials. diff --git a/platform/protocol/auth-contracts.md b/platform/protocol/auth-contracts.md new file mode 100644 index 0000000..7b47a53 --- /dev/null +++ b/platform/protocol/auth-contracts.md @@ -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. diff --git a/platform/protocol/dependency-update-contracts.md b/platform/protocol/dependency-update-contracts.md new file mode 100644 index 0000000..602eee5 --- /dev/null +++ b/platform/protocol/dependency-update-contracts.md @@ -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. diff --git a/platform/repo/client_manager_lifecycle_test.go b/platform/repo/client_manager_lifecycle_test.go new file mode 100644 index 0000000..1e6bd04 --- /dev/null +++ b/platform/repo/client_manager_lifecycle_test.go @@ -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) + } +} diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index bd057f0..bab9e1e 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -13,15 +13,29 @@ import ( ) type StoreSnapshot struct { - Users []domain.User `json:"users"` - AIProviders []domain.AIProvider `json:"aiProviders"` - GamePlugins []domain.GamePlugin `json:"gamePlugins"` - ServerInstances []domain.ServerInstance `json:"serverInstances"` - RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` - Jobs []domain.Job `json:"jobs"` - Artifacts []domain.Artifact `json:"artifacts"` - LogStreams []domain.LogStream `json:"logStreams"` - AuditEvents []domain.AuditEvent `json:"auditEvents"` + Users []domain.User `json:"users"` + AuthSessions []domain.AuthSessionRecord `json:"authSessions"` + RunControlSessions []domain.RunControlSession `json:"runControlSessions"` + AIProviders []domain.AIProvider `json:"aiProviders"` + GamePlugins []domain.GamePlugin `json:"gamePlugins"` + ServerInstances []domain.ServerInstance `json:"serverInstances"` + RunEndpoints []domain.RunEndpoint `json:"runEndpoints"` + Jobs []domain.Job `json:"jobs"` + Artifacts []domain.Artifact `json:"artifacts"` + RuntimeBindings []domain.RuntimeBinding `json:"runtimeBindings"` + EncryptedComponentKeys []domain.EncryptedComponentKey `json:"encryptedComponentKeys"` + RunDistributions []domain.RunDistribution `json:"runDistributions"` + ClientManagerDistributions []domain.ClientManagerDistribution `json:"clientManagerDistributions"` + ClientManagerInstallations []domain.ClientManagerInstallation `json:"clientManagerInstallations"` + ClientManagerSessions []domain.ClientManagerSession `json:"clientManagerSessions"` + ClientManagerNonces []domain.ClientManagerRegistrationNonce `json:"clientManagerNonces"` + DependencyStatuses []domain.DependencyStatus `json:"dependencyStatuses"` + ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"` + RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"` + LogStreams []domain.LogStream `json:"logStreams"` + AuditEvents []domain.AuditEvent `json:"auditEvents"` + MetricSamples []domain.MetricSample `json:"metricSamples"` + Backups []domain.BackupRecord `json:"backups"` } 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} } +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 { 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} } +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 { 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} } +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 { data, err := os.ReadFile(store.path) if err != nil { @@ -131,28 +201,56 @@ func (store *FileStore) persist() error { func (store *FileStore) snapshot() StoreSnapshot { return StoreSnapshot{ - Users: snapshotRepository(store.MemoryStore.users), - AIProviders: snapshotRepository(store.MemoryStore.aiProviders), - GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), - ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), - RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), - Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), - Artifacts: snapshotRepository(store.MemoryStore.artifacts), - LogStreams: snapshotRepository(store.MemoryStore.logStreams), - AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + Users: snapshotRepository(store.MemoryStore.users), + AuthSessions: snapshotRepository(store.MemoryStore.authSessions), + RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), + EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), + RunDistributions: snapshotRepository(store.MemoryStore.runDists), + ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), + ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), + ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), + ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), + DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), + ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), + RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), + Backups: snapshotRepository(store.MemoryStore.backups), } } func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { 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.gamePlugins, snapshot.GamePlugins) loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) 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.auditEvents, snapshot.AuditEvents) + loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) + loadRepository(store.MemoryStore.backups, snapshot.Backups) } type mutableRepository[T any, F any] interface { @@ -160,6 +258,7 @@ type mutableRepository[T any, F any] interface { Get(string) (T, error) List(F) ([]T, error) Update(T) error + Delete(string) error } type persistentRepository[T any, F any] struct { @@ -189,6 +288,13 @@ func (repository *persistentRepository[T, F]) Update(value T) error { 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 { *persistentRepository[domain.Job, domain.JobFilter] repository JobRepository diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 8eb29ff..4fb0b78 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -54,6 +54,14 @@ func (store *MySQLStore) Users() UserRepository { 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 { 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} } +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 { 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} } +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 { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -149,26 +205,54 @@ ON DUPLICATE KEY UPDATE snapshot_json = ?, updated_at = CURRENT_TIMESTAMP`, mysq func (store *MySQLStore) snapshot() StoreSnapshot { return StoreSnapshot{ - Users: snapshotRepository(store.MemoryStore.users), - AIProviders: snapshotRepository(store.MemoryStore.aiProviders), - GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), - ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), - RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), - Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), - Artifacts: snapshotRepository(store.MemoryStore.artifacts), - LogStreams: snapshotRepository(store.MemoryStore.logStreams), - AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + Users: snapshotRepository(store.MemoryStore.users), + AuthSessions: snapshotRepository(store.MemoryStore.authSessions), + RunControlSessions: snapshotRepository(store.MemoryStore.runSessions), + AIProviders: snapshotRepository(store.MemoryStore.aiProviders), + GamePlugins: snapshotRepository(store.MemoryStore.gamePlugins), + ServerInstances: snapshotRepository(store.MemoryStore.serverInstances), + RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints), + Jobs: snapshotRepository(store.MemoryStore.jobs.memoryRepository), + Artifacts: snapshotRepository(store.MemoryStore.artifacts), + RuntimeBindings: snapshotRepository(store.MemoryStore.runtimeBindings), + EncryptedComponentKeys: snapshotRepository(store.MemoryStore.componentKeys), + RunDistributions: snapshotRepository(store.MemoryStore.runDists), + ClientManagerDistributions: snapshotRepository(store.MemoryStore.clientDists), + ClientManagerInstallations: snapshotRepository(store.MemoryStore.clientInstalls), + ClientManagerSessions: snapshotRepository(store.MemoryStore.clientSessions), + ClientManagerNonces: snapshotRepository(store.MemoryStore.clientNonces), + DependencyStatuses: snapshotRepository(store.MemoryStore.dependencies), + ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), + RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), + LogStreams: snapshotRepository(store.MemoryStore.logStreams), + AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), + MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), + Backups: snapshotRepository(store.MemoryStore.backups), } } func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { 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.gamePlugins, snapshot.GamePlugins) loadRepository(store.MemoryStore.serverInstances, snapshot.ServerInstances) loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints) loadRepository(store.MemoryStore.jobs.memoryRepository, snapshot.Jobs) 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.auditEvents, snapshot.AuditEvents) + loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) + loadRepository(store.MemoryStore.backups, snapshot.Backups) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 0c51c5b..06fcdb1 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -20,6 +20,20 @@ type UserRepository interface { 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 { Create(domain.AIProvider) error Get(id string) (domain.AIProvider, error) @@ -91,6 +105,28 @@ type ClientManagerDistributionRepository interface { Update(domain.ClientManagerDistribution) error } +type ClientManagerInstallationRepository interface { + Create(domain.ClientManagerInstallation) error + Get(id string) (domain.ClientManagerInstallation, error) + List(domain.ClientManagerInstallationFilter) ([]domain.ClientManagerInstallation, error) + Update(domain.ClientManagerInstallation) error +} + +type ClientManagerSessionRepository interface { + Create(domain.ClientManagerSession) error + Get(id string) (domain.ClientManagerSession, error) + List(domain.ClientManagerSessionFilter) ([]domain.ClientManagerSession, error) + Update(domain.ClientManagerSession) error +} + +type ClientManagerNonceRepository interface { + Create(domain.ClientManagerRegistrationNonce) error + Get(id string) (domain.ClientManagerRegistrationNonce, error) + List(domain.ClientManagerNonceFilter) ([]domain.ClientManagerRegistrationNonce, error) + Update(domain.ClientManagerRegistrationNonce) error + Delete(id string) error +} + type DependencyStatusRepository interface { Create(domain.DependencyStatus) error Get(id string) (domain.DependencyStatus, error) @@ -126,8 +162,26 @@ type AuditEventRepository interface { 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 { Users() UserRepository + AuthSessions() AuthSessionRepository + RunControlSessions() RunControlSessionRepository AIProviders() AIProviderRepository GamePlugins() GamePluginRepository ServerInstances() ServerInstanceRepository @@ -138,15 +192,22 @@ type Store interface { EncryptedComponentKeys() EncryptedComponentKeyRepository RunDistributions() RunDistributionRepository ClientManagerDistributions() ClientManagerDistributionRepository + ClientManagerInstallations() ClientManagerInstallationRepository + ClientManagerSessions() ClientManagerSessionRepository + ClientManagerNonces() ClientManagerNonceRepository DependencyStatuses() DependencyStatusRepository ClientManagerBuildJobs() ClientManagerBuildJobRepository RunUpdateJobs() RunUpdateJobRepository LogStreams() LogStreamRepository AuditEvents() AuditEventRepository + MetricSamples() MetricSampleRepository + Backups() BackupRepository } type MemoryStore struct { users *memoryRepository[domain.User, domain.UserFilter] + authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] + runSessions *memoryRepository[domain.RunControlSession, struct{}] aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] @@ -157,11 +218,16 @@ type MemoryStore struct { componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] + clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] + clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] + clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] + metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] + backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] } func NewMemoryStore() *MemoryStore { @@ -171,6 +237,16 @@ func NewMemoryStore() *MemoryStore { domain.CopyUser, 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( func(provider domain.AIProvider) string { return provider.ID }, domain.CopyAIProvider, @@ -217,6 +293,21 @@ func NewMemoryStore() *MemoryStore { domain.CopyClientManagerDistribution, matchClientManagerDistribution, ), + clientInstalls: newMemoryRepository( + func(installation domain.ClientManagerInstallation) string { return installation.ID }, + domain.CopyClientManagerInstallation, + matchClientManagerInstallation, + ), + clientSessions: newMemoryRepository( + func(session domain.ClientManagerSession) string { return session.ID }, + domain.CopyClientManagerSession, + matchClientManagerSession, + ), + clientNonces: newMemoryRepository( + func(nonce domain.ClientManagerRegistrationNonce) string { return nonce.ID }, + domain.CopyClientManagerRegistrationNonce, + matchClientManagerNonce, + ), dependencies: newMemoryRepository( func(status domain.DependencyStatus) string { return status.ID }, domain.CopyDependencyStatus, @@ -242,17 +333,29 @@ func NewMemoryStore() *MemoryStore { domain.CopyAuditEvent, 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) AIProviders() AIProviderRepository { return store.aiProviders } -func (store *MemoryStore) GamePlugins() GamePluginRepository { return store.gamePlugins } -func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances } -func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints } -func (store *MemoryStore) Jobs() JobRepository { return store.jobs } -func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts } -func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { return store.runtimeBindings } +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) GamePlugins() GamePluginRepository { return store.gamePlugins } +func (store *MemoryStore) ServerInstances() ServerInstanceRepository { return store.serverInstances } +func (store *MemoryStore) RunEndpoints() RunEndpointRepository { return store.runEndpoints } +func (store *MemoryStore) Jobs() JobRepository { return store.jobs } +func (store *MemoryStore) Artifacts() ArtifactRepository { return store.artifacts } +func (store *MemoryStore) RuntimeBindings() RuntimeBindingRepository { return store.runtimeBindings } func (store *MemoryStore) EncryptedComponentKeys() EncryptedComponentKeyRepository { return store.componentKeys } @@ -260,6 +363,15 @@ func (store *MemoryStore) RunDistributions() RunDistributionRepository { return func (store *MemoryStore) ClientManagerDistributions() ClientManagerDistributionRepository { return store.clientDists } +func (store *MemoryStore) ClientManagerInstallations() ClientManagerInstallationRepository { + return store.clientInstalls +} +func (store *MemoryStore) ClientManagerSessions() ClientManagerSessionRepository { + return store.clientSessions +} +func (store *MemoryStore) ClientManagerNonces() ClientManagerNonceRepository { + return store.clientNonces +} func (store *MemoryStore) DependencyStatuses() DependencyStatusRepository { return store.dependencies } func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobRepository { return store.buildJobs @@ -267,6 +379,8 @@ func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobReposito func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } 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 { mu sync.RWMutex @@ -341,6 +455,16 @@ func (repository *memoryRepository[T, F]) Update(value T) error { 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 { *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 } +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 { return (filter.Kind == "" || provider.Kind == filter.Kind) && (filter.Status == "" || provider.Status == filter.Status) @@ -444,6 +574,25 @@ func matchClientManagerDistribution(distribution domain.ClientManagerDistributio (filter.Status == "" || distribution.Status == filter.Status) } +func matchClientManagerInstallation(installation domain.ClientManagerInstallation, filter domain.ClientManagerInstallationFilter) bool { + return (filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) && + (filter.ProfileKey == "" || installation.ProfileKey == filter.ProfileKey) && + (filter.RunEndpointID == "" || installation.RunEndpointID == filter.RunEndpointID) && + (filter.Status == "" || installation.Status == filter.Status) +} + +func matchClientManagerSession(session domain.ClientManagerSession, filter domain.ClientManagerSessionFilter) bool { + return (filter.InstallationID == "" || session.InstallationID == filter.InstallationID) && + (filter.ServerInstanceID == "" || session.ServerInstanceID == filter.ServerInstanceID) && + (filter.ProfileKey == "" || session.ProfileKey == filter.ProfileKey) && + (filter.Status == "" || session.Status == filter.Status) +} + +func matchClientManagerNonce(nonce domain.ClientManagerRegistrationNonce, filter domain.ClientManagerNonceFilter) bool { + return (filter.InstallationID == "" || nonce.InstallationID == filter.InstallationID) && + (filter.ExpiresBefore.IsZero() || nonce.ExpiresAt.Before(filter.ExpiresBefore) || nonce.ExpiresAt.Equal(filter.ExpiresBefore)) +} + func matchDependencyStatus(status domain.DependencyStatus, filter domain.DependencyStatusFilter) bool { return (filter.ServerInstanceID == "" || status.ServerInstanceID == filter.ServerInstanceID) && (filter.ProbeKey == "" || status.ProbeKey == filter.ProbeKey) && @@ -472,3 +621,14 @@ func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bo (filter.ResourceID == "" || event.ResourceID == filter.ResourceID) && (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) +} diff --git a/platform/repo/resources_test.go b/platform/repo/resources_test.go index ef01628..57d66e0 100644 --- a/platform/repo/resources_test.go +++ b/platform/repo/resources_test.go @@ -1,10 +1,13 @@ package repo import ( + "encoding/json" "errors" + "os" "path/filepath" "strings" "testing" + "time" "browser.local/platform/domain" ) @@ -75,7 +78,6 @@ func TestMemoryJobRepositoryFindsIdempotencyKey(t *testing.T) { if err := store.Jobs().Create(job); err != nil { t.Fatalf("create job: %v", err) } - got, err := store.Jobs().GetByIdempotency("run-local", "idem-1") if err != nil { t.Fatalf("get by idempotency: %v", err) @@ -105,17 +107,77 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { if err := store.Users().Create(user); err != nil { t.Fatalf("create user: %v", err) } + stamp := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) job := domain.Job{ - ID: "job-1", - RunEndpointID: "run-local", - ServerInstanceID: "server-1", - Capability: "process.start", - IdempotencyKey: "idem-1", - State: domain.JobStateQueued, + ID: "job-1", + RunEndpointID: "run-local", + ServerInstanceID: "server-1", + Capability: "process.start", + IdempotencyKey: "idem-1", + 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 { 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) if err != nil { @@ -132,9 +194,72 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { if err != nil { 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) } + 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) { diff --git a/platform/service/artifact_body_store.go b/platform/service/artifact_body_store.go new file mode 100644 index 0000000..cb7a81a --- /dev/null +++ b/platform/service/artifact_body_store.go @@ -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 +} diff --git a/platform/service/artifact_download.go b/platform/service/artifact_download.go index 958a47a..848e25d 100644 --- a/platform/service/artifact_download.go +++ b/platform/service/artifact_download.go @@ -1,6 +1,7 @@ package service import ( + "errors" "fmt" "net/url" "sort" @@ -8,10 +9,11 @@ import ( "time" "browser.local/platform/domain" + "browser.local/platform/repo" "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) { 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 { 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)) for _, session := range svc.artifactTransfers { @@ -183,6 +191,10 @@ func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) { if int64(len(payload)) != session.SizeBytes { 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 } diff --git a/platform/service/artifact_transfer.go b/platform/service/artifact_transfer.go index 52016d1..163ba83 100644 --- a/platform/service/artifact_transfer.go +++ b/platform/service/artifact_transfer.go @@ -82,6 +82,9 @@ func (svc *CoreService) OpenArtifactTransfer(open domain.ArtifactTransferOpen) ( CreatedAt: stamp, UpdatedAt: stamp, } + if err := svc.artifactStore.SaveTransfer(session); err != nil { + return domain.ArtifactTransferOpenResult{}, err + } svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) return artifactTransferOpenResult(session, artifact, false, stamp), nil } @@ -123,6 +126,9 @@ func (svc *CoreService) UploadArtifactChunk(chunk domain.ArtifactChunkUpload) (d ReceivedAt: stamp, } session.UpdatedAt = stamp + if err := svc.artifactStore.SaveTransfer(session); err != nil { + return domain.ArtifactChunkUploadResult{}, err + } svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) 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 { 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 { return domain.ArtifactTransferCompleteResult{}, err } + svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload) session.Completed = true session.UpdatedAt = stamp + if err := svc.artifactStore.SaveTransfer(session); err != nil { + return domain.ArtifactTransferCompleteResult{}, err + } svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil } diff --git a/platform/service/artifact_transfer_test.go b/platform/service/artifact_transfer_test.go index eda2b73..a1e2378 100644 --- a/platform/service/artifact_transfer_test.go +++ b/platform/service/artifact_transfer_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "strings" "testing" @@ -76,6 +77,11 @@ func TestCoreServiceArtifactTransferWorkflow(t *testing.T) { if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) { 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) { diff --git a/platform/service/auth_sessions.go b/platform/service/auth_sessions.go new file mode 100644 index 0000000..ee15776 --- /dev/null +++ b/platform/service/auth_sessions.go @@ -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[:]) +} diff --git a/platform/service/auth_sessions_test.go b/platform/service/auth_sessions_test.go new file mode 100644 index 0000000..607cece --- /dev/null +++ b/platform/service/auth_sessions_test.go @@ -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)) +} diff --git a/platform/service/client_manager_lifecycle.go b/platform/service/client_manager_lifecycle.go new file mode 100644 index 0000000..8c5f2b8 --- /dev/null +++ b/platform/service/client_manager_lifecycle.go @@ -0,0 +1,1423 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "sort" + "strconv" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +const ( + clientManagerRegistrationWindow = 5 * time.Minute + clientManagerSessionTTL = 15 * time.Minute +) + +func (svc *CoreService) DeployClientManagerForSession(sessionID string, request domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) { + if strings.TrimSpace(request.IdempotencyKey) == "" { + request.IdempotencyKey = "client-manager-deploy-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.DistributionID + } + if err := validator.ValidateClientManagerDeployRequest(request); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + user, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied") + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) + if err != nil { + _ = svc.recordAuditEvent(user.ID, "client-manager.deploy.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager deployment denied: distribution ownership, target, revision, or key fence is invalid") + return domain.ClientManagerLifecycleView{}, err + } + installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if request.ExpectedDeploymentGeneration > 0 && request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { + return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale") + } + if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { + if existing.ServerInstanceID != instance.ID || existing.Capability != domain.JobCapabilityClientManagerDeploy || existing.TargetKey != "client-manager/"+profile.Key || installation.CurrentJobID != existing.ID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy idempotency key conflicts with another job") + } + return svc.clientManagerLifecycleView(installation) + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + + stamp := svc.now() + installation.RunEndpointID = endpoint.ID + installation.TargetOS = distribution.TargetOS + installation.TargetArch = distribution.TargetArch + installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) + installation.DesiredRevision = distribution.SourceRevision + installation.DesiredArtifactID = distribution.ArtifactID + installation.Checksum = distribution.Checksum + installation.KeyGeneration = distribution.KeyGeneration + installation.DeploymentGeneration++ + installation.LastOperation = domain.ClientManagerOperationDeploy + installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationDeploy, installation.ID, request.IdempotencyKey) + installation.Phase = "deployment queued" + installation.Health = domain.ClientManagerHealthUnknown + installation.HealthReason = "awaiting verified deployment" + installation.Retryable = false + installation.RequiresRedeploy = false + installation.UpdatedAt = stamp + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleDeploying); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := validator.ValidateClientManagerInstallation(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerDeploy, "deployment queued")) + if err != nil { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = "deployment dispatch failed" + installation.HealthReason = "Run endpoint rejected deployment" + installation.Retryable = true + installation.UpdatedAt = svc.now() + _ = svc.store.ClientManagerInstallations().Update(installation) + return domain.ClientManagerLifecycleView{}, err + } + if job.ID != installation.CurrentJobID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid") + } + if err := svc.recordAuditEvent(user.ID, "client-manager.deploy", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager deployment for current artifact and key generation"); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) ControlClientManagerForSession(sessionID string, request domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) { + if err := validator.ValidateClientManagerControlRequest(request); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + capability := domain.JobCapabilityClientManagerControl + if request.Operation == domain.ClientManagerOperationRollback { + capability = domain.JobCapabilityClientManagerRollback + } + user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied") + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if !containsString(profile.Lifecycle.Actions, string(request.Operation)) { + _ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation)+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager control denied: action is not declared") + return domain.ClientManagerLifecycleView{}, ErrForbidden + } + installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.RequiresRedeploy { + return domain.ClientManagerLifecycleView{}, validationError("client-manager deployment generation is stale or requires redeploy") + } + if installation.ActiveArtifactID == "" || installation.Status == domain.ClientManagerLifecycleUninstalled || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { + return domain.ClientManagerLifecycleView{}, validationError("client-manager state does not allow control") + } + if request.Operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { + return domain.ClientManagerLifecycleView{}, validationError("client-manager previous deployment is unavailable") + } + if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { + if installation.CurrentJobID != existing.ID || existing.Capability != capability { + return domain.ClientManagerLifecycleView{}, validationError("client-manager control idempotency key conflicts with another job") + } + return svc.clientManagerLifecycleView(installation) + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + + installation.LastOperation = request.Operation + installation.CurrentJobID = clientManagerJobID(request.Operation, installation.ID, request.IdempotencyKey) + installation.Phase = string(request.Operation) + " queued" + installation.Retryable = false + installation.UpdatedAt = svc.now() + switch request.Operation { + case domain.ClientManagerOperationStop: + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + case domain.ClientManagerOperationRollback: + installation.DesiredArtifactID = installation.PreviousArtifactID + installation.DesiredVersion = installation.PreviousVersion + installation.DesiredRevision = installation.PreviousRevision + installation.DeploymentGeneration++ + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRollingBack); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleRegistering); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + } + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) + if err != nil { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = string(request.Operation) + " dispatch failed" + installation.Retryable = true + installation.UpdatedAt = svc.now() + _ = svc.store.ClientManagerInstallations().Update(installation) + return domain.ClientManagerLifecycleView{}, err + } + if job.ID != installation.CurrentJobID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid") + } + _ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation), "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager "+string(request.Operation)+" operation") + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) { + if err := validator.ValidateClientManagerUpdateRequest(request); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied") + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if !containsString(profile.Lifecycle.Actions, "update") || profile.UpdatePolicy.Strategy != "manual-staged" || !profile.UpdatePolicy.RequireApproval { + return domain.ClientManagerLifecycleView{}, ErrForbidden + } + installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || installation.ActiveArtifactID == "" || installation.RequiresRedeploy { + return domain.ClientManagerLifecycleView{}, validationError("client-manager update fence is stale or no healthy baseline is installed") + } + distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) + if err != nil { + _ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact scope or generation is invalid") + return domain.ClientManagerLifecycleView{}, err + } + if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) { + _ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact is not compatible with the active deployment") + return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible") + } + if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { + if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUpdate { + return domain.ClientManagerLifecycleView{}, validationError("client-manager update idempotency key conflicts with another job") + } + return svc.clientManagerLifecycleView(installation) + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + installation.DesiredArtifactID = distribution.ArtifactID + installation.DesiredVersion = clientManagerDistributionVersion(distribution, profile) + installation.DesiredRevision = distribution.SourceRevision + installation.Checksum = distribution.Checksum + installation.KeyGeneration = distribution.KeyGeneration + installation.DeploymentGeneration++ + installation.LastOperation = domain.ClientManagerOperationUpdate + installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUpdate, installation.ID, request.IdempotencyKey) + installation.Phase = "staged update queued" + installation.Retryable = false + installation.UpdatedAt = svc.now() + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleUpdating); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.revokeClientManagerSessions(installation.ID, "update activation requires a new component session"); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUpdate, installation.Phase)) + if err != nil { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = "update dispatch failed" + installation.Retryable = true + installation.UpdatedAt = svc.now() + _ = svc.store.ClientManagerInstallations().Update(installation) + return domain.ClientManagerLifecycleView{}, err + } + if job.ID != installation.CurrentJobID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid") + } + _ = svc.recordAuditEvent(user.ID, "client-manager.update", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued approved staged client-manager update with rollback retention") + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) UninstallClientManagerForSession(sessionID string, request domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) { + if err := validator.ValidateClientManagerUninstallRequest(request); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied") + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if !containsString(profile.Lifecycle.Actions, "uninstall") { + return domain.ClientManagerLifecycleView{}, ErrForbidden + } + installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration { + return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall generation is stale") + } + if installation.Status == domain.ClientManagerLifecycleUninstalled { + return svc.clientManagerLifecycleView(installation) + } + if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { + if installation.CurrentJobID != existing.ID || existing.Capability != domain.JobCapabilityClientManagerUninstall { + return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall idempotency key conflicts with another job") + } + return svc.clientManagerLifecycleView(installation) + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + installation.LastOperation = domain.ClientManagerOperationUninstall + installation.CurrentJobID = clientManagerJobID(domain.ClientManagerOperationUninstall, installation.ID, request.IdempotencyKey) + installation.Phase = "safe uninstall queued" + installation.UpdatedAt = svc.now() + if err := setClientManagerLifecycleStatus(&installation, domain.ClientManagerLifecycleStopping); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.revokeClientManagerSessions(installation.ID, "uninstall requested"); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, domain.JobCapabilityClientManagerUninstall, installation.Phase)) + if err != nil { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = "uninstall dispatch failed" + installation.Retryable = true + installation.UpdatedAt = svc.now() + _ = svc.store.ClientManagerInstallations().Update(installation) + return domain.ClientManagerLifecycleView{}, err + } + if job.ID != installation.CurrentJobID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid") + } + _ = svc.recordAuditEvent(user.ID, "client-manager.uninstall", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued safe controlled-workspace uninstall") + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + installation, err := svc.getClientManagerInstallation(instance.ID, request.ProfileKey) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.revokeClientManagerSessions(installation.ID, "operator revoked component session"); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if installation.Status != domain.ClientManagerLifecycleUninstalled { + installation.Status = domain.ClientManagerLifecycleOffline + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "component session revoked" + installation.Phase = "registration required" + installation.UpdatedAt = svc.now() + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + } + _ = svc.recordAuditEvent(user.ID, "client-manager.revoke", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "revoked Client Manager component session without exposing token material") + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, request domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) { + if err := validator.ValidateClientManagerRetryRequest(request); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + installation, err := svc.getClientManagerInstallation(request.ServerInstanceID, request.ProfileKey) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if request.ExpectedDeploymentGeneration != installation.DeploymentGeneration || !installation.Retryable || installation.LastOperation == "" { + return domain.ClientManagerLifecycleView{}, validationError("client-manager lifecycle operation is not retryable or generation is stale") + } + capability := domain.JobCapabilityClientManagerControl + switch installation.LastOperation { + case domain.ClientManagerOperationDeploy: + capability = domain.JobCapabilityClientManagerDeploy + case domain.ClientManagerOperationUpdate: + capability = domain.JobCapabilityClientManagerUpdate + case domain.ClientManagerOperationRollback: + capability = domain.JobCapabilityClientManagerRollback + case domain.ClientManagerOperationUninstall: + capability = domain.JobCapabilityClientManagerUninstall + } + user, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied") + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { + if installation.CurrentJobID != existing.ID || existing.Capability != capability { + return domain.ClientManagerLifecycleView{}, validationError("client-manager retry idempotency key conflicts with another job") + } + return svc.clientManagerLifecycleView(installation) + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + installation.CurrentJobID = clientManagerJobID(installation.LastOperation, installation.ID, request.IdempotencyKey) + installation.Phase = string(installation.LastOperation) + " retry queued" + installation.Retryable = false + switch installation.LastOperation { + case domain.ClientManagerOperationDeploy: + installation.Status = domain.ClientManagerLifecycleDeploying + case domain.ClientManagerOperationUpdate: + installation.Status = domain.ClientManagerLifecycleUpdating + case domain.ClientManagerOperationRollback: + installation.Status = domain.ClientManagerLifecycleRollingBack + case domain.ClientManagerOperationStop, domain.ClientManagerOperationUninstall: + installation.Status = domain.ClientManagerLifecycleStopping + default: + installation.Status = domain.ClientManagerLifecycleRegistering + } + installation.UpdatedAt = svc.now() + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + job, err := svc.CreateJob(clientManagerLifecycleJob(installation, request.IdempotencyKey, capability, installation.Phase)) + if err != nil { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Retryable = true + installation.UpdatedAt = svc.now() + _ = svc.store.ClientManagerInstallations().Update(installation) + return domain.ClientManagerLifecycleView{}, err + } + if job.ID != installation.CurrentJobID { + return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid") + } + _ = svc.recordAuditEvent(user.ID, "client-manager.retry", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued bounded retry using the existing deployment generation fence") + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) GetClientManagerLifecycleForSession(sessionID, serverInstanceID, profileKey string) (domain.ClientManagerLifecycleView, error) { + if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + if err := svc.ReconcileClientManagerLifecycle(); err != nil { + return domain.ClientManagerLifecycleView{}, err + } + installation, err := svc.getClientManagerInstallation(serverInstanceID, profileKey) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + return svc.clientManagerLifecycleView(installation) +} + +func (svc *CoreService) ListClientManagerLifecyclesForSession(sessionID, serverInstanceID string) ([]domain.ClientManagerLifecycleView, error) { + if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil { + return nil, err + } + if err := svc.ReconcileClientManagerLifecycle(); err != nil { + return nil, err + } + installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID}) + if err != nil { + return nil, err + } + views := make([]domain.ClientManagerLifecycleView, 0, len(installations)) + for _, installation := range installations { + view, err := svc.clientManagerLifecycleView(installation) + if err != nil { + return nil, err + } + views = append(views, view) + } + sort.Slice(views, func(i, j int) bool { return views[i].Installation.ProfileKey < views[j].Installation.ProfileKey }) + return views, nil +} + +func clientManagerLifecycleJob(installation domain.ClientManagerInstallation, idempotencyKey, capability, message string) domain.Job { + return domain.Job{ID: installation.CurrentJobID, ServerInstanceID: installation.ServerInstanceID, RunEndpointID: installation.RunEndpointID, Capability: capability, TargetKey: "client-manager/" + installation.ProfileKey, InputRef: "input://client-manager-lifecycle/" + installation.ID + "/" + strconv.Itoa(installation.DeploymentGeneration), IdempotencyKey: idempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}} +} + +func clientManagerJobID(operation domain.ClientManagerLifecycleOperation, installationID, idempotencyKey string) string { + return jobIDFromParts("job-client-manager-"+string(operation), installationID, idempotencyKey) +} + +func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanceID, profileKey, capability, deniedAction string) (domain.User, domain.ServerInstance, domain.GamePlugin, domain.RuntimeClientManagerProfile, domain.RunEndpoint, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + if err := svc.validateDistributionPluginPermission(user.ID, plugin, instance.ID, "server.client-manager.manage", deniedAction); err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, deniedAction); err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + profile, err := findRuntimeClientManagerProfile(plugin, profileKey) + if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) { + _ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: profile or capability is not declared") + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + if err := validateRunnableEndpoint(endpoint, capability); err != nil { + _ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: assigned Run endpoint is offline or unsupported") + return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err + } + return user, instance, plugin, profile, endpoint, nil +} + +func (svc *CoreService) authorizedClientManagerDistribution(instance domain.ServerInstance, profile domain.RuntimeClientManagerProfile, distributionID string) (domain.ClientManagerDistribution, error) { + distribution, err := svc.store.ClientManagerDistributions().Get(distributionID) + if err != nil { + return domain.ClientManagerDistribution{}, err + } + if distribution.ServerInstanceID != instance.ID || distribution.PluginID != instance.PluginID || distribution.ProfileKey != profile.Key || distribution.Status != domain.DistributionStatusAvailable || !clientManagerProfileSupportsTarget(profile, distribution.TargetOS, distribution.TargetArch) || !clientManagerProfileAllowsRevision(profile, distribution.SourceRevision) { + return domain.ClientManagerDistribution{}, validationError("client-manager distribution is outside the lifecycle scope") + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.ClientManagerDistribution{}, err + } + if distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture { + return domain.ClientManagerDistribution{}, validationError("client-manager distribution target does not match assigned Run endpoint") + } + key, err := svc.activeComponentKey(instance.ID, domain.DistributionComponentClientManager, profile.Key) + if err != nil || key.Generation != distribution.KeyGeneration { + return domain.ClientManagerDistribution{}, validationError("client-manager distribution key generation is no longer current") + } + artifact, err := svc.store.Artifacts().Get(distribution.ArtifactID) + if err != nil { + return domain.ClientManagerDistribution{}, err + } + if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != distribution.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID { + return domain.ClientManagerDistribution{}, validationError("client-manager distribution artifact is unavailable or outside its build job") + } + return domain.CopyClientManagerDistribution(distribution), nil +} + +func findRuntimeClientManagerProfile(plugin domain.GamePlugin, profileKey string) (domain.RuntimeClientManagerProfile, error) { + plugin = domain.CopyGamePlugin(plugin) + for _, profile := range plugin.RuntimeProfiles.ClientManagers { + if profile.Key == strings.TrimSpace(profileKey) { + return profile, nil + } + } + return domain.RuntimeClientManagerProfile{}, repo.ErrNotFound +} + +func clientManagerProfileSupportsTarget(profile domain.RuntimeClientManagerProfile, targetOS, targetArch string) bool { + for _, target := range profile.SupportedTargets { + if target.OS == targetOS && target.Arch == targetArch { + return true + } + } + return false +} + +func clientManagerProfileRevision(profile domain.RuntimeClientManagerProfile) string { + switch profile.RevisionPolicy { + case "pinned": + return profile.Revision + case "tag": + return profile.Tag + default: + return profile.Branch + } +} + +func clientManagerProfileAllowsRevision(profile domain.RuntimeClientManagerProfile, revision string) bool { + revision = strings.TrimSpace(revision) + if revision == "" { + return false + } + switch profile.RevisionPolicy { + case "pinned": + return revision == profile.Revision + case "tag": + return revision == profile.Tag || revision == profile.Revision + case "branch": + return revision == profile.Branch || profile.Revision != "" && revision == profile.Revision + default: + return false + } +} + +func clientManagerDistributionVersion(distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile) string { + if strings.TrimSpace(distribution.Version) != "" { + return distribution.Version + } + return profile.Version +} + +func (svc *CoreService) ensureClientManagerInstallationFromDistribution(instance domain.ServerInstance, plugin domain.GamePlugin, distribution domain.ClientManagerDistribution) (domain.ClientManagerInstallation, error) { + installationID := distributionID("client-manager-installation", instance.ID, distribution.ProfileKey) + installation, err := svc.store.ClientManagerInstallations().Get(installationID) + if err == nil { + return installation, nil + } + if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerInstallation{}, err + } + status := domain.ClientManagerLifecycleBuilding + phase := "build in progress" + if distribution.Status == domain.DistributionStatusAvailable { + status = domain.ClientManagerLifecycleAvailable + phase = "artifact available" + } else if distribution.Status == domain.DistributionStatusFailed || distribution.Status == domain.DistributionStatusRevoked { + status = domain.ClientManagerLifecycleFailed + phase = "build unavailable" + } + stamp := svc.now() + installation = domain.ClientManagerInstallation{ID: installationID, ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: distribution.ProfileKey, RunEndpointID: instance.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, Status: status, Phase: phase, DesiredVersion: distribution.Version, DesiredRevision: distribution.SourceRevision, DesiredArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, KeyGeneration: distribution.KeyGeneration, Health: domain.ClientManagerHealthUnknown, HealthReason: "component is not installed", CreatedAt: stamp, UpdatedAt: stamp} + if err := validator.ValidateClientManagerInstallation(installation); err != nil { + return domain.ClientManagerInstallation{}, err + } + if err := svc.store.ClientManagerInstallations().Create(installation); err != nil { + if errors.Is(err, repo.ErrDuplicate) { + return svc.store.ClientManagerInstallations().Get(installation.ID) + } + return domain.ClientManagerInstallation{}, err + } + return installation, nil +} + +func (svc *CoreService) ProjectClientManagerDistribution(distribution domain.ClientManagerDistribution) error { + instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) + if err != nil { + return err + } + plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) + if err != nil { + return err + } + installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) + if err != nil { + return err + } + if installation.ActiveArtifactID != "" || installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { + return nil + } + installation.DesiredVersion = distribution.Version + installation.DesiredRevision = distribution.SourceRevision + installation.DesiredArtifactID = distribution.ArtifactID + installation.TargetOS = distribution.TargetOS + installation.TargetArch = distribution.TargetArch + installation.Checksum = distribution.Checksum + installation.KeyGeneration = distribution.KeyGeneration + switch distribution.Status { + case domain.DistributionStatusBuilding: + installation.Status = domain.ClientManagerLifecycleBuilding + installation.Phase = "build in progress" + case domain.DistributionStatusAvailable: + installation.Status = domain.ClientManagerLifecycleAvailable + installation.Phase = "artifact available" + case domain.DistributionStatusFailed, domain.DistributionStatusRevoked: + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = "build unavailable" + installation.RequiresRedeploy = distribution.Status == domain.DistributionStatusRevoked + } + installation.UpdatedAt = svc.now() + return svc.store.ClientManagerInstallations().Update(installation) +} + +func (svc *CoreService) getClientManagerInstallation(serverInstanceID, profileKey string) (domain.ClientManagerInstallation, error) { + items, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) + if err != nil { + return domain.ClientManagerInstallation{}, err + } + if len(items) == 0 { + return domain.ClientManagerInstallation{}, repo.ErrNotFound + } + sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) + return items[0], nil +} + +func setClientManagerLifecycleStatus(installation *domain.ClientManagerInstallation, status domain.ClientManagerLifecycleStatus) error { + if err := validator.ValidateClientManagerLifecycleTransition(installation.Status, status); err != nil { + return err + } + installation.Status = status + return nil +} + +func clientManagerVersionAllowed(profile domain.RuntimeClientManagerProfile, current, desired string) bool { + currentParts, currentOK := parseClientManagerVersion(current) + desiredParts, desiredOK := parseClientManagerVersion(desired) + if !desiredOK { + return false + } + if currentOK && !profile.Compatibility.AllowDowngrade && compareClientManagerVersion(desiredParts, currentParts) < 0 { + return false + } + if minimum, ok := parseClientManagerVersion(profile.Compatibility.MinimumVersion); ok && compareClientManagerVersion(desiredParts, minimum) < 0 { + return false + } + if maximum, ok := parseClientManagerVersion(profile.Compatibility.MaximumVersion); ok && compareClientManagerVersion(desiredParts, maximum) > 0 { + return false + } + return true +} + +func parseClientManagerVersion(value string) ([3]int, bool) { + var result [3]int + parts := strings.SplitN(strings.SplitN(value, "-", 2)[0], ".", 4) + if len(parts) != 3 { + return result, false + } + for i, part := range parts { + parsed, err := strconv.Atoi(part) + if err != nil || parsed < 0 { + return [3]int{}, false + } + result[i] = parsed + } + return result, true +} + +func compareClientManagerVersion(left, right [3]int) int { + for i := 0; i < 3; i++ { + if left[i] < right[i] { + return -1 + } + if left[i] > right[i] { + return 1 + } + } + return 0 +} + +func (svc *CoreService) GetClientManagerLifecycleInput(request domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) { + if err := validator.ValidateClientManagerLifecycleInputRequest(request); err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken) + if err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt) + if err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + if !isClientManagerJobCapability(job.Capability) || job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { + return domain.ClientManagerLifecycleInput{}, validationError("job is not an active client-manager lifecycle job") + } + installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) + if err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + if installation.CurrentJobID != job.ID || installation.RunEndpointID != request.RunEndpointID || installation.DeploymentGeneration <= 0 || job.InputRef != "input://client-manager-lifecycle/"+installation.ID+"/"+strconv.Itoa(installation.DeploymentGeneration) { + return domain.ClientManagerLifecycleInput{}, validationError("client-manager lifecycle input fence is stale") + } + instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) + if err != nil || instance.RunEndpointID != request.RunEndpointID { + return domain.ClientManagerLifecycleInput{}, validationError("client-manager endpoint ownership fence is stale") + } + plugin, err := svc.store.GamePlugins().Get(installation.PluginID) + if err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) + if err != nil { + return domain.ClientManagerLifecycleInput{}, err + } + key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) + if err != nil || key.Generation != installation.KeyGeneration || installation.RequiresRedeploy { + return domain.ClientManagerLifecycleInput{}, validationError("client-manager component key generation is stale") + } + operation := installation.LastOperation + artifactID := installation.DesiredArtifactID + checksum := installation.Checksum + version := installation.DesiredVersion + revision := installation.DesiredRevision + if operation == domain.ClientManagerOperationStart || operation == domain.ClientManagerOperationStop || operation == domain.ClientManagerOperationRestart || operation == domain.ClientManagerOperationStatus || operation == domain.ClientManagerOperationUninstall { + artifactID = installation.ActiveArtifactID + version = installation.ActiveVersion + revision = installation.ActiveRevision + checksum = "" + } + return domain.CopyClientManagerLifecycleInput(domain.ClientManagerLifecycleInput{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, Operation: operation, ArtifactID: artifactID, Checksum: checksum, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Version: version, SourceRevision: revision, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, ExecutableRef: profile.Deployment.ExecutableRef, Arguments: profile.Deployment.Arguments, AutoStart: profile.Deployment.AutoStart, StartupTimeoutSeconds: profile.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: profile.Lifecycle.StopTimeoutSeconds, HealthConfirmationSeconds: profile.UpdatePolicy.HealthConfirmationSeconds, IdempotencyKey: job.IdempotencyKey}), nil +} + +func (svc *CoreService) ReadClientManagerLifecycleChunk(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 !isClientManagerJobCapability(job.Capability) || job.Capability == domain.JobCapabilityClientManagerControl || job.Capability == domain.JobCapabilityClientManagerUninstall { + return domain.RunUpdateChunk{}, validationError("job does not carry a client-manager artifact") + } + installation, err := svc.getClientManagerInstallation(job.ServerInstanceID, strings.TrimPrefix(job.TargetKey, "client-manager/")) + if err != nil || installation.CurrentJobID != job.ID || installation.DesiredArtifactID == "" { + return domain.RunUpdateChunk{}, validationError("client-manager artifact fence is stale") + } + artifact, err := svc.store.Artifacts().Get(installation.DesiredArtifactID) + if err != nil { + return domain.RunUpdateChunk{}, err + } + if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != installation.Checksum { + return domain.RunUpdateChunk{}, validationError("client-manager artifact is unavailable or checksum changed") + } + payload, err := svc.artifactPayload(artifact.ID) + if err != nil { + return domain.RunUpdateChunk{}, err + } + if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum || request.Offset >= artifact.SizeBytes { + return domain.RunUpdateChunk{}, validationError("client-manager artifact content or offset is invalid") + } + length := request.Length + if remaining := artifact.SizeBytes - request.Offset; 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 isClientManagerJobCapability(capability string) bool { + switch capability { + case domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall: + return true + default: + return false + } +} + +func (svc *CoreService) projectClientManagerLifecycleProgress(job domain.Job, stamp time.Time) error { + if !isClientManagerJobCapability(job.Capability) { + return nil + } + installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) + if err != nil { + return err + } + for _, installation := range installations { + if installation.CurrentJobID != job.ID { + continue + } + installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job running") + installation.UpdatedAt = stamp + return svc.store.ClientManagerInstallations().Update(installation) + } + return repo.ErrNotFound +} + +func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stamp time.Time) error { + if !isClientManagerJobCapability(job.Capability) { + return nil + } + installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: job.ServerInstanceID}) + if err != nil { + return err + } + for _, installation := range installations { + if installation.CurrentJobID != job.ID { + continue + } + installation.Phase = safeClientManagerLifecycleMessage(job.Progress.Message, "lifecycle job finished") + installation.UpdatedAt = stamp + installation.Retryable = false + success := job.State == domain.JobStateSucceeded + if !success { + installation.Retryable = job.State == domain.JobStateFailed + installation.HealthReason = "client-manager " + string(installation.LastOperation) + " did not complete" + if installation.LastOperation == domain.ClientManagerOperationUpdate && job.ExecutionResult.Kind == "client-manager.rollback.restored" { + installation.Status = domain.ClientManagerLifecycleDegraded + installation.Phase = "update failed; previous deployment restored" + installation.Health = domain.ClientManagerHealthDegraded + } else if job.State == domain.JobStateCancelled && installation.ActiveArtifactID != "" { + installation.Status = domain.ClientManagerLifecycleOffline + installation.Health = domain.ClientManagerHealthOffline + } else { + installation.Status = domain.ClientManagerLifecycleFailed + installation.Health = domain.ClientManagerHealthUnhealthy + } + } else { + switch installation.LastOperation { + case domain.ClientManagerOperationDeploy: + installation.ActiveArtifactID = installation.DesiredArtifactID + installation.ActiveVersion = installation.DesiredVersion + installation.ActiveRevision = installation.DesiredRevision + installation.Status = domain.ClientManagerLifecycleRegistering + installation.Phase = "deployed; component registration pending" + installation.InstalledAt = stamp + installation.Health = domain.ClientManagerHealthUnknown + installation.HealthReason = "awaiting component heartbeat" + installation.LastSeenAt = time.Time{} + installation.LastHeartbeatSequence = 0 + case domain.ClientManagerOperationStart, domain.ClientManagerOperationRestart: + installation.Status = domain.ClientManagerLifecycleRegistering + installation.Phase = "process started; component registration pending" + installation.Health = domain.ClientManagerHealthUnknown + installation.HealthReason = "awaiting component heartbeat" + installation.LastSeenAt = time.Time{} + installation.LastHeartbeatSequence = 0 + case domain.ClientManagerOperationStop: + installation.Status = domain.ClientManagerLifecycleOffline + installation.Phase = "process stopped" + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "stopped by operator" + case domain.ClientManagerOperationStatus: + if job.ExecutionResult.ProcessState == "running" { + installation.Status = domain.ClientManagerLifecycleRegistering + installation.Phase = "process running; heartbeat pending" + } else { + installation.Status = domain.ClientManagerLifecycleOffline + installation.Health = domain.ClientManagerHealthOffline + installation.Phase = "process is not running" + } + case domain.ClientManagerOperationUpdate: + installation.PreviousArtifactID = installation.ActiveArtifactID + installation.PreviousVersion = installation.ActiveVersion + installation.PreviousRevision = installation.ActiveRevision + installation.ActiveArtifactID = installation.DesiredArtifactID + installation.ActiveVersion = installation.DesiredVersion + installation.ActiveRevision = installation.DesiredRevision + installation.Status = domain.ClientManagerLifecycleRegistering + installation.Phase = "update activated; health registration pending" + installation.Health = domain.ClientManagerHealthUnknown + installation.HealthReason = "awaiting updated component heartbeat" + installation.LastSeenAt = time.Time{} + installation.LastHeartbeatSequence = 0 + case domain.ClientManagerOperationRollback: + oldArtifact, oldVersion, oldRevision := installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision + installation.ActiveArtifactID, installation.ActiveVersion, installation.ActiveRevision = installation.DesiredArtifactID, installation.DesiredVersion, installation.DesiredRevision + installation.PreviousArtifactID, installation.PreviousVersion, installation.PreviousRevision = oldArtifact, oldVersion, oldRevision + installation.Status = domain.ClientManagerLifecycleRegistering + installation.Phase = "rollback activated; health registration pending" + installation.Health = domain.ClientManagerHealthUnknown + installation.HealthReason = "awaiting rolled-back component heartbeat" + installation.LastSeenAt = time.Time{} + installation.LastHeartbeatSequence = 0 + case domain.ClientManagerOperationUninstall: + installation.Status = domain.ClientManagerLifecycleUninstalled + installation.Phase = "controlled workspace removed" + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "uninstalled" + installation.ActiveArtifactID = "" + installation.ActiveVersion = "" + installation.ActiveRevision = "" + installation.PreviousArtifactID = "" + installation.PreviousVersion = "" + installation.PreviousRevision = "" + installation.UninstalledAt = stamp + } + installation.LastSuccessfulJobID = job.ID + } + installation.CurrentJobID = job.ID + if err := validator.ValidateClientManagerInstallation(installation); err != nil { + return err + } + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return err + } + result := domain.AuditResultSuccess + if !success { + result = domain.AuditResultFailed + } + return svc.recordAuditEvent("run:"+installation.RunEndpointID, "client-manager."+string(installation.LastOperation), "client-manager-installation", installation.ID, result, "client-manager lifecycle job reached a bounded terminal result") + } + return repo.ErrNotFound +} + +func safeClientManagerLifecycleMessage(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + if len(value) > 160 || strings.Contains(strings.ToLower(value), "secret://") || strings.Contains(strings.ToLower(value), "bearer ") || strings.Contains(strings.ToLower(value), "password=") || strings.Contains(strings.ToLower(value), "token=") || strings.Contains(value, "tcp://") || strings.Contains(value, "unix://") || strings.Contains(value, "/Users/") { + return fallback + } + return value +} + +func (svc *CoreService) clientManagerLifecycleView(installation domain.ClientManagerInstallation) (domain.ClientManagerLifecycleView, error) { + view := domain.ClientManagerLifecycleView{Installation: domain.CopyClientManagerInstallation(installation)} + if installation.DesiredArtifactID != "" { + distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey}) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + for _, distribution := range distributions { + if distribution.ArtifactID == installation.DesiredArtifactID { + view.Distribution = distribution + break + } + } + } + if installation.CurrentJobID != "" { + job, err := svc.store.Jobs().Get(installation.CurrentJobID) + if err == nil { + view.Job = job + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerLifecycleView{}, err + } + } + instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + plugin, err := svc.store.GamePlugins().Get(installation.PluginID) + if err != nil { + return domain.ClientManagerLifecycleView{}, err + } + profile, _ := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) + endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) + view.Actions = clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr) + return domain.CopyClientManagerLifecycleView(view), nil +} + +func clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability { + operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall} + items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations)) + for _, operation := range operations { + capability := domain.JobCapabilityClientManagerControl + switch operation { + case domain.ClientManagerOperationDeploy: + capability = domain.JobCapabilityClientManagerDeploy + case domain.ClientManagerOperationUpdate: + capability = domain.JobCapabilityClientManagerUpdate + case domain.ClientManagerOperationRollback: + capability = domain.JobCapabilityClientManagerRollback + case domain.ClientManagerOperationUninstall: + capability = domain.JobCapabilityClientManagerUninstall + } + available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability) + reason := "" + if !available { + reason = "assigned Run endpoint is offline or lacks the declared capability" + } + if operation != domain.ClientManagerOperationDeploy && !containsString(profile.Lifecycle.Actions, string(operation)) { + available = false + reason = "client-manager profile does not declare this action" + } + if installation.RequiresRedeploy && operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall { + available = false + reason = "component key changed; rebuild and redeploy is required" + } + if operation == domain.ClientManagerOperationDeploy && (installation.DesiredArtifactID == "" || distribution.Status != domain.DistributionStatusAvailable) { + available = false + reason = "no available client-manager build artifact is ready" + } + if operation == domain.ClientManagerOperationRollback && installation.PreviousArtifactID == "" { + available = false + reason = "no previous deployment is retained" + } + if operation != domain.ClientManagerOperationDeploy && operation != domain.ClientManagerOperationUninstall && installation.ActiveArtifactID == "" { + available = false + reason = "client manager is not installed" + } + if installation.Status == domain.ClientManagerLifecycleDeploying || installation.Status == domain.ClientManagerLifecycleUpdating || installation.Status == domain.ClientManagerLifecycleRollingBack || installation.Status == domain.ClientManagerLifecycleStopping { + available = false + reason = "another lifecycle operation is in progress" + } + items = append(items, domain.ClientManagerLifecycleActionAvailability{Operation: operation, Available: available, Reason: reason}) + } + return items +} + +func (svc *CoreService) clientManagerRuntimeActionProjection(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint, bindingsComplete bool, bindingReason string) []domain.ServerRuntimeAction { + profiles := plugin.RuntimeProfiles.ClientManagers + hasLifecycle := false + for _, profile := range profiles { + if profile.Deployment.Mode == "run-supervised" { + hasLifecycle = true + break + } + } + installations, _ := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID}) + hasInstalled := false + hasPrevious := false + hasUpdateCandidate := false + for _, installation := range installations { + hasInstalled = hasInstalled || installation.ActiveArtifactID != "" && installation.Status != domain.ClientManagerLifecycleUninstalled + hasPrevious = hasPrevious || installation.PreviousArtifactID != "" + hasUpdateCandidate = hasUpdateCandidate || installation.DesiredArtifactID != "" && installation.DesiredArtifactID != installation.ActiveArtifactID + } + baseReason := fallbackReason(!hasLifecycle || !bindingsComplete, "plugin does not declare a complete Client Manager lifecycle", bindingReason) + return []domain.ServerRuntimeAction{ + runtimeAction("deploy-client-manager", "Deploy client manager", hasLifecycle && bindingsComplete && endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityClientManagerDeploy), "run endpoint cannot deploy client managers", baseReason)), + runtimeAction("control-client-manager", "Control client manager", hasLifecycle && hasInstalled && bindingsComplete && endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), fallbackReason(!hasInstalled || !endpointSupports(endpoint, domain.JobCapabilityClientManagerControl), "client manager is not installed or endpoint cannot control it", baseReason)), + runtimeAction("update-client-manager", "Update client manager", hasLifecycle && hasInstalled && hasUpdateCandidate && endpointSupports(endpoint, domain.JobCapabilityClientManagerUpdate), "no compatible update candidate is available"), + runtimeAction("rollback-client-manager", "Rollback client manager", hasLifecycle && hasPrevious && endpointSupports(endpoint, domain.JobCapabilityClientManagerRollback), "no retained previous deployment is available"), + runtimeAction("uninstall-client-manager", "Uninstall client manager", hasLifecycle && hasInstalled && endpointSupports(endpoint, domain.JobCapabilityClientManagerUninstall), "client manager is not installed or endpoint cannot uninstall it"), + } +} + +func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) { + request = domain.CopyClientManagerRegisterRequest(request) + if err := validator.ValidateClientManagerRegisterRequest(request); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + stamp := svc.now() + if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) { + _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: timestamp expired") + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID) + if err != nil { + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled { + _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: identity or deployment fence is stale") + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) + if err != nil || instance.RunEndpointID != installation.RunEndpointID { + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + plugin, err := svc.store.GamePlugins().Get(installation.PluginID) + if err != nil { + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) + if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) { + _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: capabilities do not match declaration") + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) + if err != nil || key.Generation != request.KeyGeneration { + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) + if err != nil { + return domain.ClientManagerRegisterResult{}, err + } + expected := clientManagerRegistrationSignature(plainKey, request) + if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 { + _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: signature mismatch") + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + nonceID := clientManagerNonceID(request.InstallationID, request.Nonce) + if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil { + _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: nonce replay") + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } else if !errors.Is(err, repo.ErrNotFound) { + return domain.ClientManagerRegisterResult{}, err + } + nonce := domain.ClientManagerRegistrationNonce{ID: nonceID, InstallationID: installation.ID, ExpiresAt: stamp.Add(clientManagerRegistrationWindow), CreatedAt: stamp} + if err := validator.ValidateClientManagerNonce(nonce); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + if err := svc.store.ClientManagerNonces().Create(nonce); err != nil { + return domain.ClientManagerRegisterResult{}, ErrUnauthorized + } + if err := svc.revokeClientManagerSessions(installation.ID, "component registered a replacement session"); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + token, err := randomToken() + if err != nil { + return domain.ClientManagerRegisterResult{}, err + } + session := domain.ClientManagerSession{ID: clientManagerSessionID(installation.ID, installation.DeploymentGeneration, token), InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, TokenHash: tokenHash(token), Capabilities: domain.CopyStringSlice(request.Capabilities), Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(clientManagerSessionTTL), CreatedAt: stamp, UpdatedAt: stamp} + if err := validator.ValidateClientManagerSession(session); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + if err := svc.store.ClientManagerSessions().Create(session); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + installation.Status = domain.ClientManagerLifecycleOnline + installation.Phase = "component registered" + installation.Health = domain.ClientManagerHealthHealthy + installation.HealthReason = "authenticated component session active" + installation.LastSeenAt = stamp + installation.LastHeartbeatSequence = 0 + installation.UpdatedAt = stamp + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerRegisterResult{}, err + } + _ = svc.recordAuditEvent("client-manager:"+installation.ID, "client-manager.register", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "Client Manager registered with an isolated expiring component session") + return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil +} + +func (svc *CoreService) AcceptClientManagerHeartbeat(heartbeat domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) { + heartbeat = domain.CopyClientManagerHeartbeat(heartbeat) + if err := validator.ValidateClientManagerHeartbeat(heartbeat); err != nil { + return domain.ClientManagerHeartbeatResult{}, err + } + stamp := svc.now() + installation, err := svc.store.ClientManagerInstallations().Get(heartbeat.InstallationID) + if err != nil { + return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized + } + sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive}) + if err != nil { + return domain.ClientManagerHeartbeatResult{}, err + } + var session domain.ClientManagerSession + for _, candidate := range sessions { + if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(tokenHash(heartbeat.SessionToken))) == 1 { + session = candidate + break + } + } + if session.ID == "" || !stamp.Before(session.ExpiresAt) || session.KeyGeneration != installation.KeyGeneration || session.DeploymentGeneration != installation.DeploymentGeneration || session.ArtifactID != installation.ActiveArtifactID || session.RunEndpointID != installation.RunEndpointID || heartbeat.Sequence <= session.LastHeartbeatSequence { + return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized + } + plugin, err := svc.store.GamePlugins().Get(installation.PluginID) + if err != nil { + return domain.ClientManagerHeartbeatResult{}, err + } + profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) + if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, heartbeat.Capabilities) || !clientManagerCapabilitiesMatch(session.Capabilities, heartbeat.Capabilities) { + return domain.ClientManagerHeartbeatResult{}, ErrUnauthorized + } + session.LastHeartbeatSequence = heartbeat.Sequence + session.LastSeenAt = stamp + session.UpdatedAt = stamp + if err := svc.store.ClientManagerSessions().Update(session); err != nil { + return domain.ClientManagerHeartbeatResult{}, err + } + installation.LastHeartbeatSequence = heartbeat.Sequence + installation.LastSeenAt = stamp + installation.Health = heartbeat.Health + installation.HealthReason = safeClientManagerLifecycleMessage(heartbeat.HealthReason, "component health reported") + installation.Status = domain.ClientManagerLifecycleOnline + if heartbeat.Health == domain.ClientManagerHealthDegraded || heartbeat.Health == domain.ClientManagerHealthUnhealthy { + installation.Status = domain.ClientManagerLifecycleDegraded + } + installation.Phase = "component heartbeat accepted" + installation.UpdatedAt = stamp + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return domain.ClientManagerHeartbeatResult{}, err + } + return domain.ClientManagerHeartbeatResult{Accepted: true, InstallationID: installation.ID, Status: installation.Status, Health: installation.Health, NextHeartbeat: profile.Health.IntervalSeconds, SessionExpiresAt: session.ExpiresAt, ServerTime: stamp}, nil +} + +func clientManagerRegistrationSignature(key string, request domain.ClientManagerRegisterRequest) string { + capabilities := domain.CopyStringSlice(request.Capabilities) + sort.Strings(capabilities) + canonical := strings.Join([]string{request.InstallationID, request.ServerInstanceID, request.ProfileKey, request.ArtifactID, request.Version, request.SourceRevision, request.TargetOS, request.TargetArch, strconv.Itoa(request.KeyGeneration), strconv.Itoa(request.DeploymentGeneration), request.Timestamp.UTC().Format(time.RFC3339Nano), request.Nonce, strings.Join(capabilities, ",")}, "\n") + mac := hmac.New(sha256.New, []byte(key)) + _, _ = mac.Write([]byte(canonical)) + return "sha256:" + hex.EncodeToString(mac.Sum(nil)) +} + +func clientManagerNonceID(installationID, nonce string) string { + sum := sha256.Sum256([]byte(installationID + "\x00" + nonce)) + return hex.EncodeToString(sum[:]) +} + +func clientManagerSessionID(installationID string, generation int, token string) string { + sum := sha256.Sum256([]byte(installationID + "\x00" + strconv.Itoa(generation) + "\x00" + token)) + return "cm-session-" + hex.EncodeToString(sum[:12]) +} + +func clientManagerCapabilitiesMatch(required, actual []string) bool { + actualSet := map[string]struct{}{} + for _, capability := range actual { + switch capability { + case "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream": + actualSet[capability] = struct{}{} + default: + return false + } + } + for _, capability := range required { + if _, ok := actualSet[capability]; !ok { + return false + } + } + return true +} + +func (svc *CoreService) revokeClientManagerSessions(installationID, reason string) error { + sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installationID, Status: domain.ClientManagerSessionActive}) + if err != nil { + return err + } + stamp := svc.now() + for _, session := range sessions { + session.Status = domain.ClientManagerSessionRevoked + session.RevokedAt = stamp + session.UpdatedAt = stamp + if err := svc.store.ClientManagerSessions().Update(session); err != nil { + return err + } + } + _ = reason + return nil +} + +func (svc *CoreService) ReconcileClientManagerLifecycle() error { + stamp := svc.now() + nonces, err := svc.store.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp}) + if err != nil { + return err + } + for _, nonce := range nonces { + if err := svc.store.ClientManagerNonces().Delete(nonce.ID); err != nil && !errors.Is(err, repo.ErrNotFound) { + return err + } + } + sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{}) + if err != nil { + return err + } + for _, session := range sessions { + if session.Status == domain.ClientManagerSessionActive && !stamp.Before(session.ExpiresAt) { + session.Status = domain.ClientManagerSessionExpired + session.UpdatedAt = stamp + session.RevokedAt = stamp + if err := svc.store.ClientManagerSessions().Update(session); err != nil { + return err + } + } + } + installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{}) + if err != nil { + return err + } + for _, installation := range installations { + changed := false + if installation.CurrentJobID != "" { + job, jobErr := svc.store.Jobs().Get(installation.CurrentJobID) + if jobErr == nil && isTerminalJobState(job.State) && installation.UpdatedAt.Before(job.UpdatedAt) { + if err := svc.projectClientManagerLifecycleResult(job, job.UpdatedAt); err != nil { + return err + } + continue + } + } + instance, instanceErr := svc.store.ServerInstances().Get(installation.ServerInstanceID) + if instanceErr == nil && installation.RunEndpointID != instance.RunEndpointID && installation.Status != domain.ClientManagerLifecycleUninstalled { + if err := svc.revokeClientManagerSessions(installation.ID, "Run endpoint assignment changed"); err != nil { + return err + } + installation.Status = domain.ClientManagerLifecycleFailed + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "assigned Run endpoint changed; redeploy required" + installation.RequiresRedeploy = true + changed = true + } + if key, keyErr := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey); keyErr == nil && installation.KeyGeneration > 0 && key.Generation != installation.KeyGeneration && installation.Status != domain.ClientManagerLifecycleUninstalled { + if err := svc.revokeClientManagerSessions(installation.ID, "component key generation changed"); err != nil { + return err + } + installation.Status = domain.ClientManagerLifecycleFailed + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "component key changed; rebuild and redeploy required" + installation.RequiresRedeploy = true + changed = true + } + if installation.ActiveArtifactID != "" && !installation.LastSeenAt.IsZero() { + plugin, pluginErr := svc.store.GamePlugins().Get(installation.PluginID) + profile, profileErr := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) + if pluginErr == nil && profileErr == nil { + age := stamp.Sub(installation.LastSeenAt) + if age >= time.Duration(profile.Health.OfflineAfterSeconds)*time.Second && installation.Status != domain.ClientManagerLifecycleOffline && installation.Status != domain.ClientManagerLifecycleUninstalled { + installation.Status = domain.ClientManagerLifecycleOffline + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "component heartbeat timed out" + changed = true + } else if age >= time.Duration(profile.Health.DegradedAfterSeconds)*time.Second && installation.Status == domain.ClientManagerLifecycleOnline { + installation.Status = domain.ClientManagerLifecycleDegraded + installation.Health = domain.ClientManagerHealthDegraded + installation.HealthReason = "component heartbeat is late" + changed = true + } + } + } + if changed { + installation.UpdatedAt = stamp + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return err + } + } + } + return nil +} + +func (svc *CoreService) fenceClientManagerAfterKeyReset(serverInstanceID, profileKey string, generation int) error { + installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) + if err != nil { + return err + } + for _, installation := range installations { + if err := svc.revokeClientManagerSessions(installation.ID, "component key reset"); err != nil { + return err + } + installation.Status = domain.ClientManagerLifecycleFailed + installation.Phase = "key reset; rebuild and redeploy required" + installation.Health = domain.ClientManagerHealthOffline + installation.HealthReason = "component key generation changed" + installation.RequiresRedeploy = true + installation.KeyGeneration = generation + installation.UpdatedAt = svc.now() + if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { + return err + } + } + return nil +} + +func (svc *CoreService) resolveClientManagerDistributionID(serverInstanceID, profileKey, artifactOrDistributionID string) (string, error) { + if distribution, err := svc.store.ClientManagerDistributions().Get(artifactOrDistributionID); err == nil { + if distribution.ServerInstanceID == serverInstanceID && distribution.ProfileKey == profileKey { + return distribution.ID, nil + } + return "", ErrForbidden + } + distributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: serverInstanceID, ProfileKey: profileKey}) + if err != nil { + return "", err + } + for _, distribution := range distributions { + if distribution.ArtifactID == artifactOrDistributionID { + return distribution.ID, nil + } + } + return "", repo.ErrNotFound +} + +func safeClientManagerBridgeResult(view domain.ClientManagerLifecycleView) map[string]string { + installation := view.Installation + actions := make([]string, 0, len(view.Actions)) + for _, action := range view.Actions { + if action.Available { + actions = append(actions, string(action.Operation)) + } + } + sort.Strings(actions) + result := map[string]string{ + "installationId": installation.ID, + "profileKey": installation.ProfileKey, + "status": string(installation.Status), + "phase": installation.Phase, + "targetOS": installation.TargetOS, + "targetArch": installation.TargetArch, + "version": installation.ActiveVersion, + "previousVersion": installation.PreviousVersion, + "artifactId": installation.ActiveArtifactID, + "currentJobId": installation.CurrentJobID, + "deploymentGeneration": strconv.Itoa(installation.DeploymentGeneration), + "health": string(installation.Health), + "healthReason": installation.HealthReason, + "actions": strings.Join(actions, ","), + } + if !installation.LastSeenAt.IsZero() { + result["lastSeenAt"] = installation.LastSeenAt.UTC().Format(time.RFC3339) + } + return result +} diff --git a/platform/service/client_manager_lifecycle_test.go b/platform/service/client_manager_lifecycle_test.go new file mode 100644 index 0000000..8ffc17a --- /dev/null +++ b/platform/service/client_manager_lifecycle_test.go @@ -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"} +} diff --git a/platform/service/control.go b/platform/service/control.go index 13750b8..697c172 100644 --- a/platform/service/control.go +++ b/platform/service/control.go @@ -1,8 +1,14 @@ package service import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" "errors" "fmt" + "strconv" + "strings" "time" "browser.local/platform/domain" @@ -12,6 +18,9 @@ import ( const ( defaultHeartbeatIntervalSeconds = 15 + defaultRunSessionTTL = 24 * time.Hour + maxRunRequestClockSkew = 5 * time.Minute + maxRunRequestNonces = 8192 ) 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, DisplayName: hello.DisplayName, Version: hello.Version, + Platform: hello.Platform, + Architecture: hello.Architecture, Status: domain.RunEndpointStatusOnline, Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities), Capacity: hello.Capacity, @@ -61,23 +72,53 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R if err := svc.upsertRunEndpoint(endpoint); err != nil { return domain.RunControlHelloResult{}, err } - sessionToken := svc.nextSessionToken(hello.RunEndpointID, stamp) - svc.runSessions[hello.RunEndpointID] = domain.RunControlSession{ + previous, previousErr := svc.store.RunControlSessions().Get(hello.RunEndpointID) + 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, SessionToken: sessionToken, + SessionTokenHash: tokenHash(sessionToken), + Status: domain.AuthSessionStatusActive, + Generation: generation, CapabilityFingerprint: hello.CapabilityReport.Fingerprint, HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, CreatedAt: 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{ Accepted: true, RunEndpointID: hello.RunEndpointID, SessionToken: sessionToken, ServerTime: stamp, HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds, - FeatureFlags: []string{"control.hello", "control.heartbeat"}, + SessionExpiresAt: session.ExpiresAt, + FeatureFlags: featureFlags, }), nil } @@ -96,9 +137,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) svc.controlMu.Lock() defer svc.controlMu.Unlock() - session, exists := svc.runSessions[heartbeat.RunEndpointID] - if !exists || session.SessionToken != heartbeat.SessionToken { - return domain.RunControlHeartbeatResult{}, validationError("sessionToken is invalid") + session, err := svc.currentRunSession(heartbeat.RunEndpointID, heartbeat.SessionToken) + if err != nil { + return domain.RunControlHeartbeatResult{}, err } endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID) @@ -119,6 +160,9 @@ func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint session.CapabilityFingerprint = heartbeat.CapabilityFingerprint session.UpdatedAt = stamp + if err := svc.store.RunControlSessions().Update(session); err != nil { + return domain.RunControlHeartbeatResult{}, err + } svc.runSessions[heartbeat.RunEndpointID] = session return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{ @@ -140,7 +184,105 @@ func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error { return svc.store.RunEndpoints().Update(endpoint) } -func (svc *CoreService) nextSessionToken(runEndpointID string, stamp time.Time) string { - svc.runSessionSeq++ - return fmt.Sprintf("session:%s:%d:%d", runEndpointID, stamp.UnixNano(), svc.runSessionSeq) +func (svc *CoreService) nextSessionToken() (string, error) { + return randomToken() +} + +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 } diff --git a/platform/service/dependency_updates.go b/platform/service/dependency_updates.go new file mode 100644 index 0000000..095b90a --- /dev/null +++ b/platform/service/dependency_updates.go @@ -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") +} diff --git a/platform/service/dependency_updates_test.go b/platform/service/dependency_updates_test.go new file mode 100644 index 0000000..0e6e27e --- /dev/null +++ b/platform/service/dependency_updates_test.go @@ -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}, + } +} diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index 94178f1..bfc1bec 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -14,12 +14,13 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if err := validator.ValidateDistributionBuildInputRequest(request); err != nil { 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 } 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() if err != nil { return domain.DistributionBuildInput{}, err @@ -46,7 +47,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if key.Generation != distribution.KeyGeneration { 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 { return domain.DistributionBuildInput{}, err } @@ -58,6 +59,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, + TargetRelease: distribution.ID, PackageFormat: distribution.PackageFormat, ArtifactID: distribution.ArtifactID, OutputFilename: executableFilename("run", distribution.TargetOS), @@ -82,7 +84,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if key.Generation != distribution.KeyGeneration { 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 { return domain.DistributionBuildInput{}, err } @@ -134,6 +136,9 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim if job.Capability != domain.JobCapabilityDistributionBuild { return nil } + if err := svc.validateDistributionBuildResult(job); err != nil { + return err + } status := domain.DistributionStatusFailed buildStatus := domain.DistributionJobStatusFailed 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 { return err } + if err := svc.ProjectClientManagerDistribution(distribution); err != nil { + return err + } build, err := svc.store.ClientManagerBuildJobs().Get(job.ID) if err != nil && !errors.Is(err, repo.ErrNotFound) { return err @@ -221,6 +229,55 @@ func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp tim 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 { if targetOS == "windows" { return base + ".exe" diff --git a/platform/service/distributions.go b/platform/service/distributions.go index 3392c29..10b5739 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -1,12 +1,8 @@ package service import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" "crypto/sha256" "crypto/subtle" - "encoding/base64" "encoding/hex" "errors" "fmt" @@ -17,36 +13,6 @@ import ( "browser.local/platform/validator" ) -type generatedPackageConfig struct { - Kind string `json:"kind"` - ServerInstanceID string `json:"serverInstanceId"` - PluginID string `json:"pluginId"` - RunEndpointID string `json:"runEndpointId,omitempty"` - ProfileKey string `json:"profileKey,omitempty"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` - SecretRef string `json:"secretRef"` - KeyGeneration int `json:"keyGeneration"` - AuthKey string `json:"authKey"` -} - -type generatedClientManagerPackage struct { - Kind string `json:"kind"` - Checkout clientManagerCheckoutPlan `json:"checkout"` - Config generatedPackageConfig `json:"config"` - OutputArtifacts []string `json:"outputArtifacts"` - BuildLogRef string `json:"buildLogRef"` - KeyFingerprint string `json:"keyFingerprint"` -} - -type clientManagerCheckoutPlan struct { - RepositoryURL string `json:"repositoryUrl"` - SourceRevision string `json:"sourceRevision"` - CheckoutRef string `json:"checkoutRef"` - TargetOS string `json:"targetOs"` - TargetArch string `json:"targetArch"` -} - func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, request domain.RunDistributionGenerateRequest) (domain.RunDistribution, error) { request = domain.CopyRunDistributionGenerateRequest(request) if strings.TrimSpace(request.IdempotencyKey) == "" { @@ -159,9 +125,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st if strings.TrimSpace(request.IdempotencyKey) == "" { request.IdempotencyKey = "client-manager-" + request.ServerInstanceID + "-" + request.ProfileKey + "-" + request.TargetOS + "-" + request.TargetArch } - if strings.TrimSpace(request.SourceRevision) == "" { - request.SourceRevision = "main" - } if err := validator.ValidateClientManagerBuildRequest(request); err != nil { return domain.ClientManagerDistribution{}, err } @@ -184,6 +147,18 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: unsupported target") return domain.ClientManagerDistribution{}, err } + profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey) + if err != nil { + _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: profile is not declared") + return domain.ClientManagerDistribution{}, err + } + if strings.TrimSpace(request.SourceRevision) == "" { + request.SourceRevision = clientManagerProfileRevision(profile) + } + if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) { + _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: repository, revision, or target is not declared") + return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target") + } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil { return domain.ClientManagerDistribution{}, err } @@ -215,6 +190,7 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: request.ProfileKey, + Version: profile.Version, TargetOS: request.TargetOS, TargetArch: request.TargetArch, RepositoryURL: request.RepositoryURL, @@ -246,6 +222,7 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: request.ProfileKey, + Version: profile.Version, TargetOS: request.TargetOS, TargetArch: request.TargetArch, RepositoryURL: request.RepositoryURL, @@ -271,6 +248,9 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st } return domain.ClientManagerDistribution{}, err } + if err := svc.ProjectClientManagerDistribution(distribution); err != nil { + return domain.ClientManagerDistribution{}, err + } job, err := svc.CreateJob(domain.Job{ ID: buildJobID, ServerInstanceID: instance.ID, @@ -288,6 +268,7 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st distribution.UpdatedAt = buildJob.UpdatedAt _ = svc.store.ClientManagerBuildJobs().Update(buildJob) _ = svc.store.ClientManagerDistributions().Update(distribution) + _ = svc.ProjectClientManagerDistribution(distribution) return domain.ClientManagerDistribution{}, err } if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild { @@ -388,6 +369,11 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do if err := svc.revokeComponentDistributions(instance.ID, request.ComponentKind, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil { return domain.EncryptedComponentKey{}, err } + if request.ComponentKind == domain.DistributionComponentClientManager { + if err := svc.fenceClientManagerAfterKeyReset(instance.ID, normalizedComponentKey(request.ComponentKind, request.ComponentKey), nextGeneration); err != nil { + return domain.EncryptedComponentKey{}, err + } + } if err := svc.recordAuditEvent(user.ID, "runtime-key.reset", "server-instance", instance.ID, domain.AuditResultSuccess, "reset "+string(request.ComponentKind)+" key; previous packages revoked"); err != nil { return domain.EncryptedComponentKey{}, err } @@ -419,7 +405,7 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica _ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: stale generation") return domain.CopyComponentAuthenticationResult(result), nil } - plainKey, err := decryptRuntimeKey(key.EncryptedKey) + plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) if err != nil { return domain.ComponentAuthenticationResult{}, err } @@ -468,8 +454,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv break } } - bindingsComplete := svc.runtimeBindingsComplete(instance.ID) - bindingReason := "runtime binding is incomplete" + bindingsComplete, bindingReason := svc.runtimeBindingReadiness(instance.ID) actions := domain.ServerRuntimeActions{ ServerInstanceID: instance.ID, PluginID: plugin.ID, @@ -489,6 +474,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv runtimeAction("historical-logs", "Historical logs", endpointSupports(endpoint, domain.JobCapabilityLogsBackfill) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityLogsBackfill), "run endpoint cannot backfill logs", bindingReason)), }, } + actions.Actions = append(actions.Actions, svc.clientManagerRuntimeActionProjection(instance, plugin, endpoint, bindingsComplete, bindingReason)...) return domain.CopyServerRuntimeActions(actions), nil } @@ -500,11 +486,7 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain if err := validateRunUpdateRequest(request); err != nil { return domain.RunUpdateJob{}, err } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.RunUpdateJob{}, err - } - instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID) if err != nil { return domain.RunUpdateJob{}, err } @@ -533,6 +515,22 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: checksum mismatch") return domain.RunUpdateJob{}, validationError("checksum must match artifact") } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return domain.RunUpdateJob{}, err + } + if endpoint.Platform == "" || endpoint.Architecture == "" { + return domain.RunUpdateJob{}, validationError("Run endpoint target is not registered") + } + distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID, Status: domain.DistributionStatusAvailable}) + if err != nil { + return domain.RunUpdateJob{}, err + } + distribution, err := findRunDistributionForArtifact(distributions, artifact.ID) + if err != nil || distribution.RunEndpointID != endpoint.ID || distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture || distribution.Checksum != artifact.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID { + _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is not an approved target-matched Run distribution") + return domain.RunUpdateJob{}, validationError("artifact must be an approved target-matched Run distribution") + } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-run-update", request.ServerInstanceID, request.IdempotencyKey), ServerInstanceID: instance.ID, @@ -554,9 +552,15 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain RunEndpointID: instance.RunEndpointID, ArtifactID: artifact.ID, Checksum: artifact.Checksum, + TargetOS: distribution.TargetOS, + TargetArch: distribution.TargetArch, + TargetRelease: distribution.ID, + PreviousVersion: endpoint.Version, JobID: job.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.DistributionJobStatusQueued, + Phase: domain.RunUpdatePhaseQueued, + Message: "Run update queued", CreatedAt: stamp, UpdatedAt: stamp, } @@ -569,7 +573,7 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain if getErr != nil { return domain.RunUpdateJob{}, getErr } - if !sameRunUpdateJob(existing, updateJob) { + if !sameRunUpdateTarget(existing, updateJob) { return domain.RunUpdateJob{}, validationError("run update job already exists with different target") } return domain.CopyRunUpdateJob(existing), nil @@ -585,16 +589,16 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request domain.DependencyJobRequest) (domain.Job, error) { request = domain.CopyDependencyJobRequest(request) if strings.TrimSpace(request.IdempotencyKey) == "" { - request.IdempotencyKey = "dependencies-" + request.ServerInstanceID + "-" + request.ProbeKey + operation := "check" + if request.Install { + operation = "install-" + request.InstallPlanKey + } + request.IdempotencyKey = "dependencies-" + operation + "-" + request.ServerInstanceID + "-" + request.ProbeKey } if err := validateDependencyJobRequest(request); err != nil { return domain.Job{}, err } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.Job{}, err - } - instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + user, instance, err := svc.requireServerOwner(sessionID, request.ServerInstanceID) if err != nil { return domain.Job{}, err } @@ -609,6 +613,35 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil { return domain.Job{}, err } + resolution, err := svc.resolveDependencyContext(instance.ID) + if err != nil { + return domain.Job{}, err + } + if request.TargetOS != "" && request.TargetOS != resolution.endpoint.Platform || request.TargetArch != "" && request.TargetArch != resolution.endpoint.Architecture { + return domain.Job{}, validationError("dependency request target does not match Run endpoint") + } + request.TargetOS = resolution.endpoint.Platform + request.TargetArch = resolution.endpoint.Architecture + probe, err := declaredDependencyProbe(plugin, request.ProbeKey, request.TargetOS) + if err != nil { + return domain.Job{}, err + } + var plan domain.RuntimeInstallPlan + if request.Install { + plan, err = declaredInstallPlan(plugin, request.InstallPlanKey, request.TargetOS) + if err != nil { + return domain.Job{}, err + } + if !planTargetsProbe(plan, probe) { + return domain.Job{}, validationError("install plan does not target requested dependency probe") + } + } + expectedDigest := dependencyPlanDigest(resolution, probe, plan) + if request.Install && request.PlanDigest != expectedDigest { + _ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency install denied: reviewed plan digest is stale or missing") + return domain.Job{}, validationError("planDigest must match the current reviewed install plan") + } + request.PlanDigest = expectedDigest capability := domain.JobCapabilityDependenciesCheck targetKey := "dependencies/" + request.ProbeKey message := "dependency check queued" @@ -634,7 +667,7 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d _ = svc.recordAuditEvent(user.ID, auditAction+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: endpoint unsupported or offline") return domain.Job{}, err } - if err := svc.upsertDependencyStatus(instance, request, state, "queued through platform job"); err != nil { + if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); err != nil { return domain.Job{}, err } if err := svc.recordAuditEvent(user.ID, auditAction, "server-instance", instance.ID, domain.AuditResultQueued, message); err != nil { @@ -706,7 +739,7 @@ func (svc *CoreService) ensureActiveComponentKey(serverInstanceID string, kind d normalized := normalizedComponentKey(kind, componentKey) key, err := svc.activeComponentKey(serverInstanceID, kind, normalized) if err == nil { - plainKey, err := decryptRuntimeKey(key.EncryptedKey) + plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) return key, plainKey, err } if !errors.Is(err, repo.ErrNotFound) { @@ -742,7 +775,7 @@ func (svc *CoreService) createEncryptedComponentKey(serverInstanceID string, kin if err != nil { return domain.EncryptedComponentKey{}, "", err } - encryptedKey, err := encryptRuntimeKey(plainKey) + encryptedKey, err := svc.encryptRuntimeKey(plainKey) if err != nil { return domain.EncryptedComponentKey{}, "", err } @@ -894,9 +927,21 @@ func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, } return nil } + if existingPayload, err := svc.artifactStore.GetPayload(artifactID); err == nil { + if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum { + return validationError("artifact payload does not match metadata") + } + svc.artifactPayloads[artifactID] = domain.CopyBytes(existingPayload) + return nil + } else if !errors.Is(err, repo.ErrNotFound) { + return err + } if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum { return validationError("artifact payload does not match metadata") } + if err := svc.artifactStore.PutPayload(artifactID, payload); err != nil { + return err + } svc.artifactPayloads[artifactID] = domain.CopyBytes(payload) return nil } @@ -905,6 +950,7 @@ func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, e return existing.ServerInstanceID == expected.ServerInstanceID && existing.PluginID == expected.PluginID && existing.ProfileKey == expected.ProfileKey && + existing.Version == expected.Version && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.RepositoryURL == expected.RepositoryURL && @@ -916,17 +962,7 @@ func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, e existing.Status == expected.Status } -func sameRunUpdateJob(existing domain.RunUpdateJob, expected domain.RunUpdateJob) bool { - return existing.ServerInstanceID == expected.ServerInstanceID && - existing.RunEndpointID == expected.RunEndpointID && - existing.ArtifactID == expected.ArtifactID && - existing.Checksum == expected.Checksum && - existing.JobID == expected.JobID && - existing.IdempotencyKey == expected.IdempotencyKey && - existing.Status == expected.Status -} - -func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error { +func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, jobID string, required bool, state domain.DependencyState, message string) error { statusID := distributionID("dependency-status", instance.ID, request.ProbeKey) stamp := svc.now() status := domain.DependencyStatus{ @@ -937,8 +973,10 @@ func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, r TargetOS: request.TargetOS, TargetArch: request.TargetArch, State: state, - Required: true, + Required: required, InstallPlanKey: request.InstallPlanKey, + PlanDigest: request.PlanDigest, + JobID: jobID, Message: message, CheckedAt: stamp, UpdatedAt: stamp, @@ -955,25 +993,34 @@ func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, r } func (svc *CoreService) recordAuditEvent(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) error { + _, err := svc.recordAuditEventWithID(actorID, action, resourceKind, resourceID, result, summary) + return err +} + +func (svc *CoreService) recordAuditEventWithID(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) (string, error) { svc.auditMu.Lock() svc.auditSeq++ seq := svc.auditSeq svc.auditMu.Unlock() + stamp := svc.now() event := domain.AuditEvent{ - ID: fmt.Sprintf("audit-%s-%d", strings.ReplaceAll(action, ".", "-"), seq), + ID: fmt.Sprintf("audit-%s-%d-%d", strings.ReplaceAll(action, ".", "-"), stamp.UnixNano(), seq), ActorID: actorID, Action: action, ResourceKind: resourceKind, ResourceID: resourceID, Result: result, Summary: safeBridgeReason(summary), - CreatedAt: svc.now(), + CreatedAt: stamp, } if err := validator.ValidateAuditEvent(event); err != nil { - return err + return "", err } - return svc.store.AuditEvents().Create(event) + if err := svc.store.AuditEvents().Create(event); err != nil { + return "", err + } + return event.ID, nil } func (svc *CoreService) auditArtifactDownload(sessionID string, artifact domain.Artifact) error { @@ -1085,17 +1132,6 @@ func fingerprintForString(value string) string { return hex.EncodeToString(sum[:])[:12] } -func clientManagerCheckoutRef(repositoryURL string, sourceRevision string) string { - sourceRevision = strings.TrimSpace(sourceRevision) - if sourceRevision == "" { - sourceRevision = "main" - } - if looksLikeCommitRevision(sourceRevision) { - return "commit/" + sourceRevision - } - return "branch/" + sanitizeIDPart(sourceRevision) -} - func clientManagerOutputName(profileKey string, targetOS string) string { name := sanitizeIDPart(profileKey) if targetOS == "windows" { @@ -1104,57 +1140,6 @@ func clientManagerOutputName(profileKey string, targetOS string) string { return name } -func clientManagerBuildLog(checkout clientManagerCheckoutPlan, config generatedPackageConfig, outputs []string) string { - lines := []string{ - "client-manager checkout prepared", - "repository=" + checkout.RepositoryURL, - "sourceRevision=" + checkout.SourceRevision, - "checkoutRef=" + checkout.CheckoutRef, - "target=" + checkout.TargetOS + "/" + checkout.TargetArch, - "dependencyCheck=typed build profile accepted", - "configInjection=secret ref " + config.SecretRef + " generation " + fmt.Sprintf("%d", config.KeyGeneration), - "keyFingerprint=" + fingerprintForString(config.AuthKey), - "outputs=" + strings.Join(outputs, ","), - } - return redactDistributionLog(strings.Join(lines, "\n")) -} - -func looksLikeCommitRevision(value string) bool { - if len(value) < 7 || len(value) > 64 { - return false - } - for _, char := range value { - if (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F') || (char >= '0' && char <= '9') { - continue - } - return false - } - return true -} - -func redactDistributionLog(value string) string { - replacements := []string{ - "/Users/", "[host]/", - "password=", "password=[redacted]", - "api_key=", "api_key=[redacted]", - "secret=", "secret=[redacted]", - "Bearer ", "Bearer [redacted] ", - "sk-", "sk-[redacted]", - "unix://", "socket://", - "tcp://", "endpoint://", - "mysql://", "db://", - "sqlite://", "db://", - } - redacted := value - for i := 0; i+1 < len(replacements); i += 2 { - redacted = strings.ReplaceAll(redacted, replacements[i], replacements[i+1]) - } - if len(redacted) > 4096 { - return redacted[:4096] - } - return redacted -} - func minInt(a int, b int) int { if a < b { return a @@ -1178,24 +1163,43 @@ func fallbackReason(primary bool, primaryReason string, fallback string) string } func (svc *CoreService) runtimeBindingsComplete(serverInstanceID string) bool { - bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: serverInstanceID}) + complete, _ := svc.runtimeBindingReadiness(serverInstanceID) + return complete +} + +func (svc *CoreService) runtimeBindingReadiness(serverInstanceID string) (bool, string) { + binding, err := svc.runtimeBindingForServer(serverInstanceID) + if errors.Is(err, repo.ErrNotFound) { + return false, "runtime profile is not configured" + } if err != nil { - return false + return false, "runtime binding cannot be verified" } - for _, binding := range bindings { - if binding.Status == domain.RuntimeBindingStatusIncomplete { - return false - } + instance, err := svc.store.ServerInstances().Get(serverInstanceID) + if err != nil || binding.PluginID != instance.PluginID || binding.PluginVersion != instance.PluginVersion { + return false, "runtime binding does not match the server plugin" } - return true + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return false, "runtime profile cannot be verified" + } + binding, err = normalizeRuntimeBinding(plugin, binding) + if err != nil { + return false, "runtime binding cannot be verified" + } + if binding.Status != domain.RuntimeBindingStatusComplete { + return false, "missing logical bindings: " + strings.Join(binding.MissingKeys, ", ") + } + return true, "" } func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverInstanceID string, deniedAction string) error { - if svc.runtimeBindingsComplete(serverInstanceID) { + complete, reason := svc.runtimeBindingReadiness(serverInstanceID) + if complete { return nil } - _ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: runtime binding is incomplete") - return ErrForbidden + _ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: "+reason) + return validationError(reason) } func pluginDeclares(plugin domain.GamePlugin, permission string) bool { @@ -1239,6 +1243,9 @@ func validateDependencyJobRequest(request domain.DependencyJobRequest) error { if request.Install && !safeDistributionKey(request.InstallPlanKey) { return validationError("installPlanKey is invalid") } + if request.Install && (request.PlanDigest == "" || !strings.HasPrefix(request.PlanDigest, "sha256:") || len(request.PlanDigest) != len("sha256:")+64) { + return validationError("planDigest must be a sha256 digest") + } if containsUnsafeRequestText(request.IdempotencyKey) || containsUnsafeRequestText(request.TargetOS) || containsUnsafeRequestText(request.TargetArch) { return validationError("dependency request contains unsafe content") } @@ -1291,54 +1298,3 @@ func containsUnsafeRequestText(value string) bool { strings.Contains(lowered, "tcp://") || strings.Contains(lowered, "/users/") } - -func encryptRuntimeKey(plain string) (string, error) { - key := runtimeEncryptionKey() - block, err := aes.NewCipher(key[:]) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return "", err - } - ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) - return "enc:v1:" + base64.RawURLEncoding.EncodeToString(nonce) + ":" + base64.RawURLEncoding.EncodeToString(ciphertext), nil -} - -func decryptRuntimeKey(encrypted string) (string, error) { - parts := strings.Split(encrypted, ":") - if len(parts) != 4 || parts[0] != "enc" || parts[1] != "v1" { - return "", validationError("encrypted key format is invalid") - } - nonce, err := base64.RawURLEncoding.DecodeString(parts[2]) - if err != nil { - return "", err - } - ciphertext, err := base64.RawURLEncoding.DecodeString(parts[3]) - if err != nil { - return "", err - } - key := runtimeEncryptionKey() - block, err := aes.NewCipher(key[:]) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - plain, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", err - } - return string(plain), nil -} - -func runtimeEncryptionKey() [32]byte { - return sha256.Sum256([]byte("browser.local/platform/runtime-component-key/v1")) -} diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 786d274..2d7255c 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -9,6 +9,19 @@ import ( "browser.local/platform/repo" ) +type generatedPackageConfig struct { + Kind string + ServerInstanceID string + PluginID string + RunEndpointID string + ProfileKey string + TargetOS string + TargetArch string + SecretRef string + KeyGeneration int + AuthKey string +} + func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) @@ -83,6 +96,64 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing } } +func TestCoreServiceDistributionBuildRejectsPrematureSuccessAndCanRetryAfterUpload(t *testing.T) { + svc, session, instance := newDistributionTestFixture(t) + distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ + ServerInstanceID: instance.ID, + TargetOS: "linux", + TargetArch: "amd64", + IdempotencyKey: "idem-premature-result", + }) + if err != nil { + t.Fatalf("generate run distribution: %v", err) + } + + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityDistributionBuild) + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register build worker: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{ + RunEndpointID: instance.RunEndpointID, + SessionToken: hello.SessionToken, + Capabilities: []string{domain.JobCapabilityDistributionBuild}, + Capacity: domain.RunCapacity{MaxJobs: 1}, + }) + if err != nil || !claim.HasJob || claim.Job.JobID != distribution.BuildJobID { + t.Fatalf("claim distribution build job: claim=%+v err=%v", claim, err) + } + result := domain.RunJobResult{ + RunEndpointID: instance.RunEndpointID, + SessionToken: hello.SessionToken, + JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, + Attempt: claim.Job.Attempt, + State: domain.JobStateSucceeded, + Progress: domain.RunJobProgressReport{Percent: 100, Message: "package_finalize: done"}, + ResultRef: "artifact://" + distribution.ArtifactID, + Message: "done", + } + if _, err := svc.CompleteRunJob(result); err == nil { + t.Fatal("expected premature success without uploaded artifact to be rejected") + } + stored, err := svc.GetJob(distribution.BuildJobID) + if err != nil || stored.State != domain.JobStateAccepted { + t.Fatalf("premature success must not make the job terminal, job=%+v err=%v", stored, err) + } + + if _, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, []byte("actual compiled archive")); err != nil { + t.Fatalf("publish uploaded build output: %v", err) + } + if _, err := svc.CompleteRunJob(result); err != nil { + t.Fatalf("retry success after artifact upload: %v", err) + } + stored, err = svc.GetJob(distribution.BuildJobID) + if err != nil || stored.State != domain.JobStateSucceeded { + t.Fatalf("expected terminal success after upload, job=%+v err=%v", stored, err) + } +} + func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") @@ -124,7 +195,7 @@ func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ ServerInstanceID: instance.ID, - TargetOS: "windows", + TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "idem-run-before-update", }) @@ -347,6 +418,11 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, + domain.JobCapabilityClientManagerDeploy, + domain.JobCapabilityClientManagerControl, + domain.JobCapabilityClientManagerUpdate, + domain.JobCapabilityClientManagerRollback, + domain.JobCapabilityClientManagerUninstall, ) plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRunDistribution), @@ -354,6 +430,9 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv string(domain.PluginBridgeActionDependenciesRequest), string(domain.PluginBridgeActionLogsBackfillRequest), ) + plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}} + plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}} + plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin fixture: %v", err) } @@ -363,7 +442,14 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityLogsBackfill, + domain.JobCapabilityClientManagerDeploy, + domain.JobCapabilityClientManagerControl, + domain.JobCapabilityClientManagerUpdate, + domain.JobCapabilityClientManagerRollback, + domain.JobCapabilityClientManagerUninstall, ) + endpoint.Platform = "linux" + endpoint.Architecture = "amd64" if err := svc.store.RunEndpoints().Update(endpoint); err != nil { t.Fatalf("update endpoint fixture: %v", err) } @@ -384,6 +470,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv if err != nil { t.Fatalf("create distribution server: %v", err) } + createCompleteRuntimeBinding(t, svc, instance, "local") return svc, session, instance } diff --git a/platform/service/durable_observability_test.go b/platform/service/durable_observability_test.go new file mode 100644 index 0000000..c1b5969 --- /dev/null +++ b/platform/service/durable_observability_test.go @@ -0,0 +1,145 @@ +package service + +import ( + "bytes" + "path/filepath" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func TestFileArtifactBodyStoreResumesTransferAfterServiceRestart(t *testing.T) { + root := t.TempDir() + metadata := filepath.Join(root, "metadata.json") + store, err := repo.NewFileStore(metadata) + if err != nil { + t.Fatalf("new file store: %v", err) + } + logStore, err := NewFileLogBodyStore(filepath.Join(root, "logs")) + if err != nil { + t.Fatalf("new log store: %v", err) + } + artifactStore, err := NewFileArtifactBodyStore(filepath.Join(root, "artifacts")) + if err != nil { + t.Fatalf("new artifact store: %v", err) + } + svc, err := NewCoreServiceWithDurableStores(store, logStore, artifactStore) + if err != nil { + t.Fatalf("new durable service: %v", err) + } + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "durable-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Durable"}); err != nil { + t.Fatalf("create instance: %v", err) + } + if _, err := svc.CreateJob(domain.Job{ID: "durable-job", ServerInstanceID: "durable-server", RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "durable-job"}); err != nil { + t.Fatalf("create job: %v", err) + } + hello, err := svc.RegisterRunHello(validRunControlHello()) + if err != nil { + t.Fatalf("register run: %v", err) + } + payload := []byte("durable transfer payload") + open, err := svc.OpenArtifactTransfer(domain.ArtifactTransferOpen{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ArtifactID: "durable-artifact", Direction: domain.ArtifactTransferDirectionUpload, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "durable-job", SizeBytes: int64(len(payload)), ChunkSizeBytes: 8, Checksum: validator.BytesChecksum(payload), IdempotencyKey: "durable-transfer"}) + if err != nil { + t.Fatalf("open transfer: %v", err) + } + first := validArtifactChunk(hello.SessionToken, open.TransferID, payload, 0, 8) + first.ArtifactID = "durable-artifact" + if _, err := svc.UploadArtifactChunk(first); err != nil { + t.Fatalf("upload first chunk: %v", err) + } + + reloadedStore, err := repo.NewFileStore(metadata) + if err != nil { + t.Fatalf("reload metadata store: %v", err) + } + reloadedLogStore, err := NewFileLogBodyStore(filepath.Join(root, "logs")) + if err != nil { + t.Fatalf("reload log store: %v", err) + } + reloadedArtifacts, err := NewFileArtifactBodyStore(filepath.Join(root, "artifacts")) + if err != nil { + t.Fatalf("reload artifact store: %v", err) + } + restarted, err := NewCoreServiceWithDurableStores(reloadedStore, reloadedLogStore, reloadedArtifacts) + if err != nil { + t.Fatalf("restart service: %v", err) + } + status, err := restarted.QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, TransferID: open.TransferID, ArtifactID: "durable-artifact"}) + if err != nil { + t.Fatalf("query resumed status: %v", err) + } + if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 { + t.Fatalf("unexpected resumed transfer status: %+v", status) + } + for index := 1; index < open.TotalChunks; index++ { + chunk := validArtifactChunk(hello.SessionToken, open.TransferID, payload, index, 8) + chunk.ArtifactID = "durable-artifact" + if _, err := restarted.UploadArtifactChunk(chunk); err != nil { + t.Fatalf("upload resumed chunk %d: %v", index, err) + } + } + if _, err := restarted.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, TransferID: open.TransferID, ArtifactID: "durable-artifact", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))}); err != nil { + t.Fatalf("complete resumed transfer: %v", err) + } + finalStore, _ := repo.NewFileStore(metadata) + finalArtifacts, _ := NewFileArtifactBodyStore(filepath.Join(root, "artifacts")) + finalService, err := NewCoreServiceWithDurableStores(finalStore, reloadedLogStore, finalArtifacts) + if err != nil { + t.Fatalf("final restart service: %v", err) + } + stored, err := finalService.artifactPayload("durable-artifact") + if err != nil || !bytes.Equal(stored, payload) { + t.Fatalf("expected durable payload after restart, payload=%q err=%v", stored, err) + } +} + +func TestMetricsAndBackupsPersistWithRetentionRecovery(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "observability-owner", DisplayName: "Owner", Email: "observability-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "observability-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Observability"}) + if err != nil { + t.Fatalf("create instance: %v", err) + } + runHello := validRunControlHello() + runHello.RunEndpointID = endpoint.ID + registered, err := svc.RegisterRunHello(runHello) + if err != nil { + t.Fatalf("register run: %v", err) + } + collectedAt := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + cpu := 42.0 + if _, err := svc.IngestMetricBatch(domain.MetricBatchIngest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Samples: []domain.MetricSample{{ServerInstanceID: instance.ID, CPUPercent: &cpu, Source: "run", CollectedAt: collectedAt}}}); err != nil { + t.Fatalf("ingest metrics: %v", err) + } + metrics, err := svc.ListMetricSamplesForSession(ownerSession, domain.MetricSampleFilter{ServerInstanceID: instance.ID, Limit: 10}) + if err != nil || len(metrics) != 1 || metrics[0].CPUPercent == nil || *metrics[0].CPUPercent != cpu { + t.Fatalf("unexpected persisted metrics: %+v err=%v", metrics, err) + } + artifact, err := svc.CreateArtifact(domain.Artifact{ID: "backup-artifact", OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: instance.ID, SizeBytes: 12, Checksum: validator.BytesChecksum([]byte("backup bytes")), State: domain.ArtifactStateAvailable}) + if err != nil { + t.Fatalf("create backup artifact: %v", err) + } + backup, err := svc.CreateBackupForSession(ownerSession, domain.BackupRecord{ID: "backup-1", ServerInstanceID: instance.ID, ArtifactID: artifact.ID}) + if err != nil || backup.State != domain.BackupStatePending { + t.Fatalf("create backup record: %+v err=%v", backup, err) + } + if err := svc.RecoverIncompleteBackups(); err != nil { + t.Fatalf("recover backups: %v", err) + } + recovered, err := svc.GetBackupForSession(ownerSession, backup.ID) + if err != nil || recovered.State != domain.BackupStateFailed || recovered.RecoveryStatus == "" { + t.Fatalf("expected recoverable failed backup, record=%+v err=%v", recovered, err) + } + otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "observability-other", DisplayName: "Other", Email: "observability-other@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + if _, err := svc.ListMetricSamplesForSession(otherSession, domain.MetricSampleFilter{ServerInstanceID: instance.ID, Limit: 10}); err != ErrForbidden { + t.Fatalf("expected cross-owner metric denial, got %v", err) + } + if _, err := svc.GetBackupForSession(otherSession, backup.ID); err != ErrForbidden { + t.Fatalf("expected cross-owner backup denial, got %v", err) + } +} diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 4909b71..3061964 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -1,6 +1,7 @@ package service import ( + "crypto/subtle" "fmt" "sort" "strings" @@ -10,14 +11,22 @@ import ( "browser.local/platform/validator" ) -const defaultJobPollSeconds = 2 +const ( + defaultJobPollSeconds = 2 + defaultJobMaxAttempts = 3 + defaultJobInitialBackoffSeconds = 2 + defaultJobMaxBackoffSeconds = 60 + defaultJobAckTimeout = 15 * time.Second + defaultJobLeaseDuration = 60 * time.Second +) func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClaimResult, error) { claim = domain.CopyRunJobClaim(claim) if err := validator.ValidateRunJobClaim(claim); err != nil { return domain.RunJobClaimResult{}, err } - if err := svc.validateRunSession(claim.RunEndpointID, claim.SessionToken); err != nil { + session, err := svc.validatedRunSession(claim.RunEndpointID, claim.SessionToken) + if err != nil { return domain.RunJobClaimResult{}, err } @@ -25,31 +34,40 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai svc.jobMu.Lock() defer svc.jobMu.Unlock() - jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID, State: domain.JobStateQueued}) + if err := svc.sweepExpiredJobs(claim.RunEndpointID, stamp); err != nil { + return domain.RunJobClaimResult{}, err + } + if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { + return emptyJobClaim(claim.RunEndpointID, stamp), nil + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) if err != nil { return domain.RunJobClaimResult{}, err } - job, ok := firstSupportedJob(jobs, claim.Capabilities) + job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp) if !ok { - return domain.RunJobClaimResult{ - Accepted: true, - RunEndpointID: claim.RunEndpointID, - NextPollSeconds: defaultJobPollSeconds, - ServerTime: stamp, - }, nil + return emptyJobClaim(claim.RunEndpointID, stamp), nil } - lease := svc.newJobLease(job.ID, claim.RunEndpointID, claim.SessionToken, stamp) - svc.jobLeases[job.ID] = lease + leaseToken, err := randomToken() + if err != nil { + return domain.RunJobClaimResult{}, err + } + job = normalizeJobScheduling(job, stamp) + job.Attempt++ job.State = domain.JobStateAccepted + job.Progress = domain.JobProgress{Percent: 0, Message: "claimed; awaiting Run acknowledgement"} + job.NextAttemptAt = time.Time{} + job.LeaseTokenHash = tokenHash(leaseToken) + job.LeaseSessionGen = session.Generation + job.AckDeadlineAt = stamp.Add(defaultJobAckTimeout) + job.LeaseExpiresAt = stamp.Add(defaultJobLeaseDuration) + job.LastProgressSeq = 0 job.UpdatedAt = stamp - if err := validator.ValidateJob(job); err != nil { + if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobClaimResult{}, err } - if err := svc.store.Jobs().Update(job); err != nil { - return domain.RunJobClaimResult{}, err - } - assignment := assignmentFromJob(job, lease) + assignment := assignmentFromJob(job, leaseToken) return domain.CopyRunJobClaimResult(domain.RunJobClaimResult{ Accepted: true, RunEndpointID: claim.RunEndpointID, @@ -64,20 +82,26 @@ func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, if err := validator.ValidateRunJobAck(ack); err != nil { return domain.RunJobAckResult{}, err } - if err := svc.validateRunSession(ack.RunEndpointID, ack.SessionToken); err != nil { + session, err := svc.validatedRunSession(ack.RunEndpointID, ack.SessionToken) + if err != nil { return domain.RunJobAckResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() - job, lease, err := svc.activeLeasedJob(ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt) + job, err := svc.fencedJob(session, ack.JobID, ack.LeaseToken, ack.Attempt) if err != nil { return domain.RunJobAckResult{}, err } if isTerminalJobState(job.State) { - return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + return domain.RunJobAckResult{}, validationError("late ack rejected for terminal job") + } + if job.State == domain.JobStateAccepted && deadlineExpired(job.AckDeadlineAt, stamp) { + if err := svc.expireJobAttempt(&job, stamp, "Run acknowledgement deadline expired"); err != nil { + return domain.RunJobAckResult{}, err + } + return domain.RunJobAckResult{}, validationError("ack deadline expired") } if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { return domain.RunJobAckResult{}, validationError("job is not claimable for ack") @@ -86,92 +110,134 @@ func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, if strings.TrimSpace(ack.Message) != "" { job.Progress.Message = ack.Message } + job.AckDeadlineAt = time.Time{} + job.LeaseExpiresAt = stamp.Add(defaultJobLeaseDuration) job.UpdatedAt = stamp - if err := validator.ValidateJob(job); err != nil { + if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobAckResult{}, err } - if err := svc.store.Jobs().Update(job); err != nil { - return domain.RunJobAckResult{}, err - } - lease.UpdatedAt = stamp - svc.jobLeases[job.ID] = lease - return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, ack.LeaseToken), ServerTime: stamp}, nil } func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (domain.RunJobProgressResult, error) { if err := validator.ValidateRunJobProgress(progress); err != nil { return domain.RunJobProgressResult{}, err } - if err := svc.validateRunSession(progress.RunEndpointID, progress.SessionToken); err != nil { + session, err := svc.validatedRunSession(progress.RunEndpointID, progress.SessionToken) + if err != nil { return domain.RunJobProgressResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() - job, lease, err := svc.activeLeasedJob(progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt) + job, err := svc.fencedJob(session, progress.JobID, progress.LeaseToken, progress.Attempt) if err != nil { return domain.RunJobProgressResult{}, err } - if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { - return domain.RunJobProgressResult{}, validationError("job is not active") + if job.State != domain.JobStateRunning { + return domain.RunJobProgressResult{}, validationError("job is not running") + } + if deadlineExpired(job.LeaseExpiresAt, stamp) { + if err := svc.expireJobAttempt(&job, stamp, "Run execution lease expired"); err != nil { + return domain.RunJobProgressResult{}, err + } + return domain.RunJobProgressResult{}, validationError("job lease expired") + } + if progress.Sequence > 0 && progress.Sequence <= job.LastProgressSeq { + return domain.RunJobProgressResult{}, validationError("progress sequence is stale") } - job.State = domain.JobStateRunning job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message} - job.UpdatedAt = stamp - if err := validator.ValidateJob(job); err != nil { - return domain.RunJobProgressResult{}, err + if progress.Sequence > 0 { + job.LastProgressSeq = progress.Sequence } - if err := svc.store.Jobs().Update(job); err != nil { + job.LeaseExpiresAt = stamp.Add(defaultJobLeaseDuration) + job.UpdatedAt = stamp + if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobProgressResult{}, err } if err := svc.projectDistributionBuildProgress(job, stamp); err != nil { return domain.RunJobProgressResult{}, err } - lease.UpdatedAt = stamp - svc.jobLeases[job.ID] = lease - return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + if err := svc.projectDependencyAndRunUpdateProgress(job, stamp); err != nil { + return domain.RunJobProgressResult{}, err + } + if err := svc.projectClientManagerLifecycleProgress(job, stamp); err != nil { + return domain.RunJobProgressResult{}, err + } + return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, progress.LeaseToken), ServerTime: stamp}, nil } func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJobResultResult, error) { if err := validator.ValidateRunJobResult(result); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.validateRunSession(result.RunEndpointID, result.SessionToken); err != nil { + session, err := svc.validatedRunSession(result.RunEndpointID, result.SessionToken) + if err != nil { return domain.RunJobResultResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() - job, lease, err := svc.activeLeasedJob(result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt) + job, err := svc.fencedJob(session, result.JobID, result.LeaseToken, result.Attempt) if err != nil { return domain.RunJobResultResult{}, err } fingerprint := terminalFingerprint(result) if isTerminalJobState(job.State) { - if lease.TerminalFingerprint != "" && lease.TerminalFingerprint == fingerprint { - if err := svc.projectLifecycleJobResult(job, stamp); err != nil { - return domain.RunJobResultResult{}, err - } - if err := svc.projectDistributionBuildResult(job, stamp); err != nil { - return domain.RunJobResultResult{}, err - } - return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + if job.TerminalFingerprint == fingerprint { + return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil } return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result") } + if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { + return domain.RunJobResultResult{}, validationError("job attempt is no longer active") + } + if deadlineExpired(job.LeaseExpiresAt, stamp) { + if err := svc.expireJobAttempt(&job, stamp, "Run execution lease expired"); err != nil { + return domain.RunJobResultResult{}, err + } + return domain.RunJobResultResult{}, validationError("job lease expired") + } + if !job.CancelRequestedAt.IsZero() && result.State != domain.JobStateCancelled { + return domain.RunJobResultResult{}, validationError("cancel intent requires a cancelled terminal result") + } + if err := validateExecutionResultForJob(job, result); err != nil { + return domain.RunJobResultResult{}, err + } + + if result.State == domain.JobStateFailed && result.Retryable && job.Attempt < job.RetryPolicy.MaxAttempts && job.CancelRequestedAt.IsZero() { + job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)} + if err := svc.scheduleJobRetry(&job, stamp, "retryable Run failure"); err != nil { + return domain.RunJobResultResult{}, err + } + return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, ""), ServerTime: stamp}, nil + } job.State = result.State job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)} job.ResultRef = result.ResultRef + job.ExecutionResult = result.ExecutionResult + job.TerminalAt = stamp + job.TerminalFingerprint = fingerprint + job.AckDeadlineAt = time.Time{} + job.LeaseExpiresAt = time.Time{} + if result.State == domain.JobStateCancelled { + job.CancelCompletedAt = stamp + if job.CancelRequestedAt.IsZero() { + job.CancelRequestedAt = stamp + job.CancelReason = terminalMessage(result) + } + } job.UpdatedAt = stamp if err := validator.ValidateJob(job); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.store.Jobs().Update(job); err != nil { + if err := svc.validateDistributionBuildResult(job); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobResultResult{}, err } if err := svc.projectLifecycleJobResult(job, stamp); err != nil { @@ -180,17 +246,84 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.projectDistributionBuildResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } - lease.TerminalFingerprint = fingerprint - lease.UpdatedAt = stamp - svc.jobLeases[job.ID] = lease - return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil + if err := svc.projectRemoteAdapterJobResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.projectDependencyAndRunUpdateResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } + if err := svc.projectClientManagerLifecycleResult(job, stamp); err != nil { + return domain.RunJobResultResult{}, err + } + return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil +} + +func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error { + if result.ExecutionResult.Kind == "" { + return nil + } + switch job.Capability { + case domain.JobCapabilityConfigWrite: + if result.ExecutionResult.Kind != "file.write" { + return validationError("config write result type is invalid") + } + if result.State != domain.JobStateSucceeded { + return nil + } + if result.ExecutionResult.Version != job.ExecutionInput.ExpectedVersion+1 { + return validationError("config write result version is invalid") + } + if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) { + return validationError("config write result checksum is invalid") + } + case domain.JobCapabilityFilesRead: + if result.ExecutionResult.Kind != "file.read" { + return validationError("file read result type is invalid") + } + case domain.JobCapabilityFilesWrite: + if result.ExecutionResult.Kind != "file.write" { + return validationError("file write result type is invalid") + } + case domain.JobCapabilityDependenciesCheck: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "dependency.check" { + return validationError("dependency check result type is invalid") + } + case domain.JobCapabilityDependenciesInstall: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "dependency.install" { + return validationError("dependency install result type is invalid") + } + case domain.JobCapabilityRunSelfUpdate: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "run.update.staged" { + return validationError("Run self-update result type is invalid") + } + case domain.JobCapabilityClientManagerDeploy: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" { + return validationError("client-manager deploy result type is invalid") + } + case domain.JobCapabilityClientManagerControl: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.controlled" { + return validationError("client-manager control result type is invalid") + } + case domain.JobCapabilityClientManagerUpdate: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.updated" || result.State == domain.JobStateFailed && result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != "client-manager.rollback.restored" { + return validationError("client-manager update result type is invalid") + } + case domain.JobCapabilityClientManagerRollback: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.rolled-back" { + return validationError("client-manager rollback result type is invalid") + } + case domain.JobCapabilityClientManagerUninstall: + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.uninstalled" { + return validationError("client-manager uninstall result type is invalid") + } + } + return nil } func (svc *CoreService) RequestRunJobCancel(request domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) { if err := validator.ValidateRunJobCancelRequest(request); err != nil { return domain.RunJobCancelRequestResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() @@ -199,43 +332,59 @@ func (svc *CoreService) RequestRunJobCancel(request domain.RunJobCancelRequest) if err != nil { return domain.RunJobCancelRequestResult{}, err } - if !isActiveJobState(job.State) { - return domain.RunJobCancelRequestResult{}, validationError("job is not active") + job = normalizeJobScheduling(job, stamp) + if job.State == domain.JobStateCancelled { + return cancelRequestResult(job), nil } - lease, exists := svc.jobLeases[job.ID] - if !exists { - return domain.RunJobCancelRequestResult{}, validationError("job lease is missing") + if isTerminalJobState(job.State) { + return domain.RunJobCancelRequestResult{}, validationError("job is already terminal") } - lease.CancelReason = request.Reason - lease.CancelRequestedAt = stamp - lease.UpdatedAt = stamp - svc.jobLeases[job.ID] = lease - return domain.RunJobCancelRequestResult{Accepted: true, JobID: job.ID, Reason: request.Reason, RequestedAt: stamp}, nil + if job.CancelRequestedAt.IsZero() { + job.CancelReason = request.Reason + job.CancelRequestedAt = stamp + } + if job.State == domain.JobStateQueued || job.State == domain.JobStateRetrying { + terminalizeCancelled(&job, stamp, job.CancelReason) + } + job.UpdatedAt = stamp + if err := svc.updateScheduledJob(job); err != nil { + return domain.RunJobCancelRequestResult{}, err + } + return cancelRequestResult(job), nil } func (svc *CoreService) PollRunJobCancel(poll domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) { if err := validator.ValidateRunJobCancelPoll(poll); err != nil { return domain.RunJobCancelPollResult{}, err } - if err := svc.validateRunSession(poll.RunEndpointID, poll.SessionToken); err != nil { + session, err := svc.validatedRunSession(poll.RunEndpointID, poll.SessionToken) + if err != nil { return domain.RunJobCancelPollResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() - lease, ok := svc.findCancelLease(poll) - if !ok { + job, err := svc.fencedJob(session, poll.JobID, poll.LeaseToken, poll.Attempt) + if err != nil { + return domain.RunJobCancelPollResult{}, err + } + if jobAttemptExpired(job, stamp) { + if err := svc.expireJobAttempt(&job, stamp, "Run job deadline expired before cancel poll"); err != nil { + return domain.RunJobCancelPollResult{}, err + } + return domain.RunJobCancelPollResult{}, validationError("job lease expired") + } + if job.CancelRequestedAt.IsZero() || !isActiveJobState(job.State) { return domain.RunJobCancelPollResult{Accepted: true, RunEndpointID: poll.RunEndpointID, ServerTime: stamp}, nil } return domain.RunJobCancelPollResult{ Accepted: true, RunEndpointID: poll.RunEndpointID, HasCancel: true, - JobID: lease.JobID, - Reason: lease.CancelReason, - RequestedAt: lease.CancelRequestedAt, + JobID: job.ID, + Reason: job.CancelReason, + RequestedAt: job.CancelRequestedAt, ServerTime: stamp, }, nil } @@ -245,140 +394,198 @@ func (svc *CoreService) ReconcileRunJobs(reconcile domain.RunJobReconcile) (doma if err := validator.ValidateRunJobReconcile(reconcile); err != nil { return domain.RunJobReconcileResult{}, err } - if err := svc.validateRunSession(reconcile.RunEndpointID, reconcile.SessionToken); err != nil { + session, err := svc.validatedRunSession(reconcile.RunEndpointID, reconcile.SessionToken) + if err != nil { return domain.RunJobReconcileResult{}, err } - stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() + confirmed := make([]domain.RunJobAssignment, 0, len(reconcile.ActiveJobs)) + discard := make([]string, 0) + confirmedIDs := map[string]struct{}{} + for _, entry := range reconcile.ActiveJobs { + job, getErr := svc.store.Jobs().Get(entry.JobID) + if getErr != nil || job.RunEndpointID != reconcile.RunEndpointID || !isActiveJobState(job.State) || job.Attempt != entry.Attempt || !leaseTokenMatches(job.LeaseTokenHash, entry.LeaseToken) || jobAttemptExpired(job, stamp) { + discard = append(discard, entry.JobID) + continue + } + job = normalizeJobScheduling(job, stamp) + job.LeaseSessionGen = session.Generation + job.LeaseExpiresAt = stamp.Add(defaultJobLeaseDuration) + job.LastReconciledAt = stamp + job.ReconcileCount++ + job.ReconcileOutcome = "confirmed active attempt" + job.UpdatedAt = stamp + if err := svc.updateScheduledJob(job); err != nil { + return domain.RunJobReconcileResult{}, err + } + confirmedIDs[job.ID] = struct{}{} + confirmed = append(confirmed, assignmentFromJob(job, entry.LeaseToken)) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: reconcile.RunEndpointID}) if err != nil { return domain.RunJobReconcileResult{}, err } - activeByID := map[string]domain.Job{} for _, job := range jobs { - if isActiveJobState(job.State) { - activeByID[job.ID] = job + if !isActiveJobState(job.State) { + continue + } + if _, ok := confirmedIDs[job.ID]; ok { + continue + } + job = normalizeJobScheduling(job, stamp) + job.LastReconciledAt = stamp + job.ReconcileCount++ + job.ReconcileOutcome = "missing from Run journal" + if err := svc.expireJobAttempt(&job, stamp, "active attempt missing during Run reconciliation"); err != nil { + return domain.RunJobReconcileResult{}, err } } - - activeJobs := make([]domain.RunJobAssignment, 0, len(activeByID)) - ids := make([]string, 0, len(activeByID)) - for id := range activeByID { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { - job := activeByID[id] - lease := svc.jobLeases[job.ID] - if lease.JobID == "" || lease.SessionToken != reconcile.SessionToken { - lease = svc.newJobLease(job.ID, reconcile.RunEndpointID, reconcile.SessionToken, stamp) - } else { - lease.UpdatedAt = stamp - } - svc.jobLeases[job.ID] = lease - activeJobs = append(activeJobs, assignmentFromJob(job, lease)) - } - - unknown := make([]string, 0) - for _, reportedID := range reconcile.ActiveJobIDs { - if _, exists := activeByID[reportedID]; !exists { - unknown = append(unknown, reportedID) - } - } - sort.Strings(unknown) + sort.Strings(discard) + sort.Slice(confirmed, func(i, j int) bool { return confirmed[i].JobID < confirmed[j].JobID }) return domain.CopyRunJobReconcileResult(domain.RunJobReconcileResult{ Accepted: true, RunEndpointID: reconcile.RunEndpointID, - ActiveJobs: activeJobs, - UnknownJobIDs: unknown, + ConfirmedJobs: confirmed, + DiscardJobIDs: discard, ServerTime: stamp, }), nil } -func (svc *CoreService) validateRunSession(runEndpointID string, sessionToken string) error { +func (svc *CoreService) validatedRunSession(runEndpointID string, sessionToken string) (domain.RunControlSession, error) { svc.controlMu.Lock() defer svc.controlMu.Unlock() - session, exists := svc.runSessions[runEndpointID] - if !exists || session.SessionToken != sessionToken { - return validationError("sessionToken is invalid") + return svc.currentRunSession(runEndpointID, sessionToken) +} + +func (svc *CoreService) validateRunSession(runEndpointID string, sessionToken string) error { + _, err := svc.validatedRunSession(runEndpointID, sessionToken) + return err +} + +func (svc *CoreService) fencedJob(session domain.RunControlSession, jobID string, leaseToken string, attempt int) (domain.Job, error) { + job, err := svc.store.Jobs().Get(jobID) + if err != nil { + return domain.Job{}, err + } + job = normalizeJobScheduling(job, svc.now()) + if job.RunEndpointID != session.RunEndpointID { + return domain.Job{}, validationError("job runEndpointId does not match request") + } + if job.Attempt != attempt || job.LeaseSessionGen != session.Generation || !leaseTokenMatches(job.LeaseTokenHash, leaseToken) { + return domain.Job{}, validationError("attempt or leaseToken is invalid") + } + return job, nil +} + +func (svc *CoreService) sweepExpiredJobs(runEndpointID string, stamp time.Time) error { + jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: runEndpointID}) + if err != nil { + return err + } + for _, job := range jobs { + job = normalizeJobScheduling(job, stamp) + expired := job.State == domain.JobStateAccepted && deadlineExpired(job.AckDeadlineAt, stamp) + expired = expired || job.State == domain.JobStateRunning && deadlineExpired(job.LeaseExpiresAt, stamp) + if expired { + if err := svc.expireJobAttempt(&job, stamp, "Run job deadline expired"); err != nil { + return err + } + } } return nil } -func (svc *CoreService) newJobLease(jobID string, runEndpointID string, sessionToken string, stamp time.Time) domain.RunJobLease { - svc.jobLeaseSeq++ - return domain.RunJobLease{ - JobID: jobID, - RunEndpointID: runEndpointID, - SessionToken: sessionToken, - LeaseToken: fmt.Sprintf("job-lease:%s:%d:%d", jobID, stamp.UnixNano(), svc.jobLeaseSeq), - Attempt: int(svc.jobLeaseSeq), - CreatedAt: stamp, - UpdatedAt: stamp, +func (svc *CoreService) expireJobAttempt(job *domain.Job, stamp time.Time, reason string) error { + if !job.CancelRequestedAt.IsZero() { + terminalizeCancelled(job, stamp, job.CancelReason) + return svc.updateScheduledJob(*job) } + if job.Attempt < job.RetryPolicy.MaxAttempts { + return svc.scheduleJobRetry(job, stamp, reason) + } + job.State = domain.JobStateFailed + job.Progress = domain.JobProgress{Percent: job.Progress.Percent, Message: reason + "; retry budget exhausted"} + job.TerminalAt = stamp + job.TerminalFingerprint = fmt.Sprintf("scheduler-failed|%d|%s", job.Attempt, reason) + job.AckDeadlineAt = time.Time{} + job.LeaseExpiresAt = time.Time{} + job.UpdatedAt = stamp + return svc.updateScheduledJob(*job) } -func (svc *CoreService) activeLeasedJob(runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) (domain.Job, domain.RunJobLease, error) { - job, err := svc.store.Jobs().Get(jobID) - if err != nil { - return domain.Job{}, domain.RunJobLease{}, err - } - if job.RunEndpointID != runEndpointID { - return domain.Job{}, domain.RunJobLease{}, validationError("job runEndpointId does not match request") - } - lease, exists := svc.jobLeases[jobID] - if !exists || lease.SessionToken != sessionToken || lease.LeaseToken != leaseToken || lease.Attempt != attempt { - return domain.Job{}, domain.RunJobLease{}, validationError("leaseToken is invalid") - } - return job, lease, nil +func (svc *CoreService) scheduleJobRetry(job *domain.Job, stamp time.Time, reason string) error { + job.State = domain.JobStateRetrying + job.Progress.Message = reason + job.NextAttemptAt = stamp.Add(jobRetryBackoff(job.RetryPolicy, job.Attempt)) + job.LeaseTokenHash = "" + job.LeaseSessionGen = 0 + job.AckDeadlineAt = time.Time{} + job.LeaseExpiresAt = time.Time{} + job.LastProgressSeq = 0 + job.UpdatedAt = stamp + return svc.updateScheduledJob(*job) } -func (svc *CoreService) findCancelLease(poll domain.RunJobCancelPoll) (domain.RunJobLease, bool) { - if poll.JobID != "" { - lease, exists := svc.jobLeases[poll.JobID] - if !exists || lease.RunEndpointID != poll.RunEndpointID || lease.SessionToken != poll.SessionToken { - return domain.RunJobLease{}, false - } - if poll.LeaseToken != "" && lease.LeaseToken != poll.LeaseToken { - return domain.RunJobLease{}, false - } - return lease, lease.CancelReason != "" +func (svc *CoreService) updateScheduledJob(job domain.Job) error { + if err := validator.ValidateJob(job); err != nil { + return err } - - ids := make([]string, 0, len(svc.jobLeases)) - for id := range svc.jobLeases { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { - lease := svc.jobLeases[id] - if lease.RunEndpointID == poll.RunEndpointID && lease.SessionToken == poll.SessionToken && lease.CancelReason != "" { - return lease, true - } - } - return domain.RunJobLease{}, false + return svc.store.Jobs().Update(job) } -func firstSupportedJob(jobs []domain.Job, capabilities []string) (domain.Job, bool) { +func normalizeJobScheduling(job domain.Job, stamp time.Time) domain.Job { + if job.RetryPolicy.MaxAttempts <= 0 { + job.RetryPolicy.MaxAttempts = defaultJobMaxAttempts + } + if job.RetryPolicy.InitialBackoffSeconds <= 0 { + job.RetryPolicy.InitialBackoffSeconds = defaultJobInitialBackoffSeconds + } + if job.RetryPolicy.MaxBackoffSeconds < job.RetryPolicy.InitialBackoffSeconds { + job.RetryPolicy.MaxBackoffSeconds = defaultJobMaxBackoffSeconds + } + if job.QueueEligibleAt.IsZero() { + if !job.CreatedAt.IsZero() { + job.QueueEligibleAt = job.CreatedAt + } else { + job.QueueEligibleAt = stamp + } + } + return job +} + +func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp time.Time) (domain.Job, bool) { capabilitySet := map[string]struct{}{} for _, capability := range capabilities { capabilitySet[capability] = struct{}{} } + sort.SliceStable(jobs, func(i, j int) bool { + if jobs[i].CreatedAt.Equal(jobs[j].CreatedAt) { + return jobs[i].ID < jobs[j].ID + } + return jobs[i].CreatedAt.Before(jobs[j].CreatedAt) + }) for _, job := range jobs { - if len(capabilitySet) == 0 { - return job, true + job = normalizeJobScheduling(job, stamp) + eligible := job.State == domain.JobStateQueued && !stamp.Before(job.QueueEligibleAt) + eligible = eligible || job.State == domain.JobStateRetrying && !stamp.Before(job.NextAttemptAt) + if !eligible || !job.CancelRequestedAt.IsZero() { + continue } - if _, supported := capabilitySet[job.Capability]; supported { - return job, true + if len(capabilitySet) > 0 { + if _, supported := capabilitySet[job.Capability]; !supported { + continue + } } + return job, true } return domain.Job{}, false } -func assignmentFromJob(job domain.Job, lease domain.RunJobLease) domain.RunJobAssignment { +func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment { return domain.RunJobAssignment{ JobID: job.ID, ServerInstanceID: job.ServerInstanceID, @@ -390,15 +597,76 @@ func assignmentFromJob(job domain.Job, lease domain.RunJobLease) domain.RunJobAs State: job.State, Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message}, ResultRef: job.ResultRef, - LeaseToken: lease.LeaseToken, - Attempt: lease.Attempt, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds}, + LeaseToken: leaseToken, + Attempt: job.Attempt, + MaxAttempts: job.RetryPolicy.MaxAttempts, + AckDeadlineAt: job.AckDeadlineAt, + LeaseExpiresAt: job.LeaseExpiresAt, + NextAttemptAt: job.NextAttemptAt, + ProgressSequence: job.LastProgressSeq, CreatedAt: job.CreatedAt, UpdatedAt: job.UpdatedAt, } } +func emptyJobClaim(runEndpointID string, stamp time.Time) domain.RunJobClaimResult { + return domain.RunJobClaimResult{Accepted: true, RunEndpointID: runEndpointID, NextPollSeconds: defaultJobPollSeconds, ServerTime: stamp} +} + +func cancelRequestResult(job domain.Job) domain.RunJobCancelRequestResult { + return domain.RunJobCancelRequestResult{ + Accepted: true, JobID: job.ID, Reason: job.CancelReason, RequestedAt: job.CancelRequestedAt, + CompletedAt: job.CancelCompletedAt, State: job.State, + } +} + +func terminalizeCancelled(job *domain.Job, stamp time.Time, reason string) { + job.State = domain.JobStateCancelled + job.Progress = domain.JobProgress{Percent: job.Progress.Percent, Message: reason} + job.CancelCompletedAt = stamp + job.TerminalAt = stamp + job.TerminalFingerprint = fmt.Sprintf("scheduler-cancelled|%d|%s", job.Attempt, reason) + job.NextAttemptAt = time.Time{} + job.AckDeadlineAt = time.Time{} + job.LeaseExpiresAt = time.Time{} +} + +func leaseTokenMatches(expectedHash string, token string) bool { + if expectedHash == "" || strings.TrimSpace(token) == "" { + return false + } + actual := tokenHash(token) + return subtle.ConstantTimeCompare([]byte(expectedHash), []byte(actual)) == 1 +} + +func deadlineExpired(deadline time.Time, stamp time.Time) bool { + return deadline.IsZero() || !stamp.Before(deadline) +} + +func jobAttemptExpired(job domain.Job, stamp time.Time) bool { + if job.State == domain.JobStateAccepted { + return deadlineExpired(job.AckDeadlineAt, stamp) + } + if job.State == domain.JobStateRunning { + return deadlineExpired(job.LeaseExpiresAt, stamp) + } + return false +} + +func jobRetryBackoff(policy domain.JobRetryPolicy, attempt int) time.Duration { + delay := int64(policy.InitialBackoffSeconds) + for current := 1; current < attempt && delay < int64(policy.MaxBackoffSeconds); current++ { + delay *= 2 + if delay > int64(policy.MaxBackoffSeconds) { + delay = int64(policy.MaxBackoffSeconds) + } + } + return time.Duration(delay) * time.Second +} + func terminalFingerprint(result domain.RunJobResult) string { - return fmt.Sprintf("%s|%d|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message) + return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable) } func terminalMessage(result domain.RunJobResult) string { diff --git a/platform/service/job_channel_test.go b/platform/service/job_channel_test.go index 93a1002..78390ec 100644 --- a/platform/service/job_channel_test.go +++ b/platform/service/job_channel_test.go @@ -164,7 +164,7 @@ func TestCoreServiceRunJobCancelPoll(t *testing.T) { t.Fatalf("unexpected cancel request: %+v", cancel) } - poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: "job-1", LeaseToken: claim.Job.LeaseToken}) + poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: "job-1", LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) if err != nil { t.Fatalf("poll cancel: %v", err) } @@ -223,15 +223,18 @@ func TestCoreServiceRunJobReconcile(t *testing.T) { t.Fatalf("ack job: %v", err) } - reconcile, err := svc.ReconcileRunJobs(domain.RunJobReconcile{RunEndpointID: "run-local", SessionToken: sessionToken, ActiveJobIDs: []string{"job-1", "local-only"}}) + reconcile, err := svc.ReconcileRunJobs(domain.RunJobReconcile{RunEndpointID: "run-local", SessionToken: sessionToken, ActiveJobs: []domain.RunJobReconcileEntry{ + {JobID: "job-1", LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}, + {JobID: "local-only", LeaseToken: "local-lease", Attempt: 1}, + }}) if err != nil { t.Fatalf("reconcile jobs: %v", err) } - if len(reconcile.ActiveJobs) != 1 || reconcile.ActiveJobs[0].JobID != "job-1" { + if len(reconcile.ConfirmedJobs) != 1 || reconcile.ConfirmedJobs[0].JobID != "job-1" { t.Fatalf("expected platform active job, got %+v", reconcile) } - if len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" { - t.Fatalf("expected unknown local job, got %+v", reconcile.UnknownJobIDs) + if len(reconcile.DiscardJobIDs) != 1 || reconcile.DiscardJobIDs[0] != "local-only" { + t.Fatalf("expected unknown local job, got %+v", reconcile.DiscardJobIDs) } } diff --git a/platform/service/job_scheduler_test.go b/platform/service/job_scheduler_test.go new file mode 100644 index 0000000..27c0580 --- /dev/null +++ b/platform/service/job_scheduler_test.go @@ -0,0 +1,241 @@ +package service + +import ( + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +func TestDurableJobPlatformRestartPreservesLeaseFencing(t *testing.T) { + store := repo.NewMemoryStore() + stamp := fixedTime + now := func() time.Time { return stamp } + svc, sessionToken := newMutableRunJobService(t, store, now) + createQueuedRunJob(t, svc, "job-restart", "idem-restart") + claim := mustClaimJob(t, svc, sessionToken, "run-local") + + restarted := newCoreService(store, now) + ack, err := restarted.AckRunJob(domain.RunJobAck{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "recovered after restart", + }) + if err != nil || ack.Job.State != domain.JobStateRunning { + t.Fatalf("restart ack failed: ack=%+v err=%v", ack, err) + } + stored, err := store.Jobs().Get("job-restart") + if err != nil { + t.Fatalf("get stored job: %v", err) + } + if stored.LeaseTokenHash == "" || stored.LeaseTokenHash == claim.Job.LeaseToken || stored.Attempt != 1 || stored.LeaseSessionGen != 1 { + t.Fatalf("expected hashed durable lease metadata, got %+v", stored) + } +} + +func TestDurableJobAckTimeoutBackoffAndAttemptFencing(t *testing.T) { + store := repo.NewMemoryStore() + stamp := fixedTime + now := func() time.Time { return stamp } + svc, sessionToken := newMutableRunJobService(t, store, now) + createQueuedRunJob(t, svc, "job-timeout", "idem-timeout") + first := mustClaimJob(t, svc, sessionToken, "run-local") + + stamp = stamp.Add(defaultJobAckTimeout) + _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: first.Job.JobID, LeaseToken: first.Job.LeaseToken, Attempt: first.Job.Attempt}) + if err == nil || !strings.Contains(err.Error(), "ack deadline") { + t.Fatalf("expected late ack rejection, got %v", err) + } + retrying, _ := svc.GetJob(first.Job.JobID) + if retrying.State != domain.JobStateRetrying || !retrying.NextAttemptAt.Equal(stamp.Add(2*time.Second)) { + t.Fatalf("expected persisted retry wait, got %+v", retrying) + } + empty, err := svc.ClaimRunJob(runJobClaim(sessionToken, "run-local")) + if err != nil || empty.HasJob { + t.Fatalf("job claimed before backoff elapsed: %+v err=%v", empty, err) + } + + stamp = retrying.NextAttemptAt + second := mustClaimJob(t, svc, sessionToken, "run-local") + if second.Job.Attempt != first.Job.Attempt+1 || second.Job.LeaseToken == first.Job.LeaseToken { + t.Fatalf("expected fenced second attempt, first=%+v second=%+v", first.Job, second.Job) + } + _, err = svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: first.Job.JobID, + LeaseToken: first.Job.LeaseToken, Attempt: first.Job.Attempt, State: domain.JobStateSucceeded, + Progress: domain.RunJobProgressReport{Percent: 100}, Message: "late result", + }) + if err == nil || !strings.Contains(err.Error(), "attempt or leaseToken") { + t.Fatalf("expected old attempt result rejection, got %v", err) + } +} + +func TestDurableJobLeaseExpiryAndRetryBudget(t *testing.T) { + store := repo.NewMemoryStore() + stamp := fixedTime + now := func() time.Time { return stamp } + svc, sessionToken := newMutableRunJobService(t, store, now) + _, err := svc.CreateJob(domain.Job{ + ID: "job-retry", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-retry", + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 2, InitialBackoffSeconds: 3, MaxBackoffSeconds: 3}, + }) + if err != nil { + t.Fatalf("create retry job: %v", err) + } + first := mustClaimJob(t, svc, sessionToken, "run-local") + mustAckJob(t, svc, sessionToken, first.Job) + stamp = stamp.Add(defaultJobLeaseDuration) + _, err = svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: first.Job.JobID, + LeaseToken: first.Job.LeaseToken, Attempt: first.Job.Attempt, Sequence: 1, + Progress: domain.RunJobProgressReport{Percent: 20, Message: "late progress"}, + }) + if err == nil || !strings.Contains(err.Error(), "lease expired") { + t.Fatalf("expected expired lease rejection, got %v", err) + } + retrying, _ := svc.GetJob(first.Job.JobID) + if retrying.State != domain.JobStateRetrying { + t.Fatalf("expected retrying after lease expiry, got %+v", retrying) + } + + stamp = retrying.NextAttemptAt + second := mustClaimJob(t, svc, sessionToken, "run-local") + mustAckJob(t, svc, sessionToken, second.Job) + result, err := svc.CompleteRunJob(domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: second.Job.JobID, + LeaseToken: second.Job.LeaseToken, Attempt: second.Job.Attempt, State: domain.JobStateFailed, + Progress: domain.RunJobProgressReport{Percent: 100, Message: "still failing"}, Message: "still failing", Retryable: true, + }) + if err != nil || result.Job.State != domain.JobStateFailed { + t.Fatalf("expected terminal failure after retry budget, result=%+v err=%v", result, err) + } +} + +func TestDurableJobCancellationBeforeAndAfterClaimIsIdempotent(t *testing.T) { + store := repo.NewMemoryStore() + stamp := fixedTime + now := func() time.Time { return stamp } + svc, sessionToken := newMutableRunJobService(t, store, now) + createQueuedRunJob(t, svc, "job-cancel-queued", "idem-cancel-queued") + firstCancel, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: "job-cancel-queued", Reason: "operator cancelled queue"}) + if err != nil || firstCancel.State != domain.JobStateCancelled || firstCancel.CompletedAt.IsZero() { + t.Fatalf("cancel queued job: result=%+v err=%v", firstCancel, err) + } + repeated, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: "job-cancel-queued", Reason: "operator cancelled queue"}) + if err != nil || !repeated.CompletedAt.Equal(firstCancel.CompletedAt) { + t.Fatalf("repeat cancel was not idempotent: result=%+v err=%v", repeated, err) + } + + createQueuedRunJob(t, svc, "job-cancel-running", "idem-cancel-running") + claim := mustClaimJob(t, svc, sessionToken, "run-local") + mustAckJob(t, svc, sessionToken, claim.Job) + intent, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: claim.Job.JobID, Reason: "operator stop"}) + if err != nil || intent.State != domain.JobStateRunning || !intent.CompletedAt.IsZero() { + t.Fatalf("cancel active intent: result=%+v err=%v", intent, err) + } + poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, + }) + if err != nil || !poll.HasCancel || poll.Reason != "operator stop" { + t.Fatalf("poll cancel: result=%+v err=%v", poll, err) + } + terminalRequest := domain.RunJobResult{ + RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, + LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateCancelled, + Progress: domain.RunJobProgressReport{Percent: 100, Message: "cancelled"}, Message: "cancelled", + } + terminal, err := svc.CompleteRunJob(terminalRequest) + if err != nil || terminal.Job.State != domain.JobStateCancelled { + t.Fatalf("complete cancellation: result=%+v err=%v", terminal, err) + } + if _, err := svc.CompleteRunJob(terminalRequest); err != nil { + t.Fatalf("duplicate cancelled result should be idempotent: %v", err) + } +} + +func TestDurableJobReconcileRotatedSessionAndMissingAttempt(t *testing.T) { + store := repo.NewMemoryStore() + stamp := fixedTime + now := func() time.Time { return stamp } + svc, oldSession := newMutableRunJobService(t, store, now) + createQueuedRunJob(t, svc, "job-confirmed", "idem-confirmed") + confirmedClaim := mustClaimJob(t, svc, oldSession, "run-local") + mustAckJob(t, svc, oldSession, confirmedClaim.Job) + createQueuedRunJob(t, svc, "job-missing", "idem-missing") + missingClaim := mustClaimJob(t, svc, oldSession, "run-local") + mustAckJob(t, svc, oldSession, missingClaim.Job) + + hello := validRunControlHello() + hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, "process.start") + hello.CapabilityReport.Fingerprint = "cap-jobs-rotated" + rotated, err := svc.RegisterRunHello(hello) + if err != nil { + t.Fatalf("rotate Run session: %v", err) + } + _, err = svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", SessionToken: oldSession, JobID: confirmedClaim.Job.JobID, + LeaseToken: confirmedClaim.Job.LeaseToken, Attempt: confirmedClaim.Job.Attempt, + Progress: domain.RunJobProgressReport{Percent: 20}, Sequence: 1, + }) + if err == nil { + t.Fatal("expected rotated Run session to reject progress") + } + + reconciled, err := svc.ReconcileRunJobs(domain.RunJobReconcile{ + RunEndpointID: "run-local", SessionToken: rotated.SessionToken, + ActiveJobs: []domain.RunJobReconcileEntry{{JobID: confirmedClaim.Job.JobID, LeaseToken: confirmedClaim.Job.LeaseToken, Attempt: confirmedClaim.Job.Attempt}}, + }) + if err != nil || len(reconciled.ConfirmedJobs) != 1 || len(reconciled.DiscardJobIDs) != 0 { + t.Fatalf("reconcile rotated session: result=%+v err=%v", reconciled, err) + } + progress, err := svc.UpdateRunJobProgress(domain.RunJobProgress{ + RunEndpointID: "run-local", SessionToken: rotated.SessionToken, JobID: confirmedClaim.Job.JobID, + LeaseToken: confirmedClaim.Job.LeaseToken, Attempt: confirmedClaim.Job.Attempt, + Progress: domain.RunJobProgressReport{Percent: 30, Message: "reconciled"}, Sequence: 1, + }) + if err != nil || progress.Job.Progress.Percent != 30 { + t.Fatalf("progress after reconcile: result=%+v err=%v", progress, err) + } + missing, _ := svc.GetJob(missingClaim.Job.JobID) + if missing.State != domain.JobStateRetrying || missing.ReconcileOutcome != "missing from Run journal" { + t.Fatalf("expected missing active attempt to retry, got %+v", missing) + } +} + +func newMutableRunJobService(t *testing.T, store repo.Store, now func() time.Time) (*CoreService, string) { + t.Helper() + svc := newCoreService(store, now) + hello := validRunControlHello() + hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, "process.start") + hello.CapabilityReport.Fingerprint = "cap-jobs" + result, err := svc.RegisterRunHello(hello) + if err != nil { + t.Fatalf("register Run: %v", err) + } + return svc, result.SessionToken +} + +func runJobClaim(sessionToken string, endpointID string) domain.RunJobClaim { + return domain.RunJobClaim{RunEndpointID: endpointID, SessionToken: sessionToken, Capabilities: []string{"process.start"}, Capacity: domain.RunCapacity{MaxJobs: 4}} +} + +func mustClaimJob(t *testing.T, svc *CoreService, sessionToken string, endpointID string) domain.RunJobClaimResult { + t.Helper() + claim, err := svc.ClaimRunJob(runJobClaim(sessionToken, endpointID)) + if err != nil || !claim.HasJob || claim.Job == nil { + t.Fatalf("claim job: result=%+v err=%v", claim, err) + } + return claim +} + +func mustAckJob(t *testing.T, svc *CoreService, sessionToken string, job *domain.RunJobAssignment) { + t.Helper() + if _, err := svc.AckRunJob(domain.RunJobAck{ + RunEndpointID: job.RunEndpointID, SessionToken: sessionToken, JobID: job.JobID, + LeaseToken: job.LeaseToken, Attempt: job.Attempt, + }); err != nil { + t.Fatalf("ack job %s: %v", job.JobID, err) + } +} diff --git a/platform/service/log_body_store.go b/platform/service/log_body_store.go index a1abde3..a183ee9 100644 --- a/platform/service/log_body_store.go +++ b/platform/service/log_body_store.go @@ -86,6 +86,18 @@ func (store *MemoryLogBodyStore) Query(streamID string, afterSeq uint64, limit i return selected, nextSeq, nil } +func (store *MemoryLogBodyStore) LatestSeq(streamID string) (uint64, error) { + store.mu.Lock() + defer store.mu.Unlock() + var latest uint64 + for _, entry := range store.entries[streamID] { + if entry.Seq > latest { + latest = entry.Seq + } + } + return latest, nil +} + type FileLogBodyStore struct { mu sync.Mutex rootDir string @@ -101,7 +113,7 @@ func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) { rootDir: rootDir, memory: NewMemoryLogBodyStore(), } - if err := os.MkdirAll(rootDir, 0o755); err != nil { + if err := os.MkdirAll(rootDir, 0o700); err != nil { return nil, fmt.Errorf("create log directory: %w", err) } if err := store.load(); err != nil { @@ -124,7 +136,7 @@ func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBat return store.memory.AppendBatch(streamID, record) } streamDir := store.streamDir(streamID) - if err := os.MkdirAll(streamDir, 0o755); err != nil { + if err := os.MkdirAll(streamDir, 0o700); err != nil { return fmt.Errorf("create log stream directory: %w", err) } segmentPath := store.segmentPath(streamID, record.FirstSeq) @@ -133,23 +145,15 @@ func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBat } else if !os.IsNotExist(err) { return fmt.Errorf("stat log segment: %w", err) } - tmpPath := segmentPath + ".tmp" - file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) - if err != nil { - return fmt.Errorf("open log segment: %w", err) - } - encoder := json.NewEncoder(file) + var body strings.Builder + encoder := json.NewEncoder(&body) for _, entry := range record.Entries { if err := encoder.Encode(domain.CopyLogEntry(entry)); err != nil { - _ = file.Close() return fmt.Errorf("write log segment: %w", err) } } - if err := file.Close(); err != nil { - return fmt.Errorf("close log segment: %w", err) - } - if err := os.Rename(tmpPath, segmentPath); err != nil { - return fmt.Errorf("replace log segment: %w", err) + if err := writeAtomicFile(segmentPath, []byte(body.String()), 0o600); err != nil { + return fmt.Errorf("persist log segment: %w", err) } return store.memory.AppendBatch(streamID, record) } @@ -162,6 +166,10 @@ func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int return store.memory.Query(streamID, afterSeq, limit) } +func (store *FileLogBodyStore) LatestSeq(streamID string) (uint64, error) { + return store.memory.LatestSeq(streamID) +} + func (store *FileLogBodyStore) load() error { entries, err := os.ReadDir(store.rootDir) if err != nil { diff --git a/platform/service/observability.go b/platform/service/observability.go new file mode 100644 index 0000000..dff0fa2 --- /dev/null +++ b/platform/service/observability.go @@ -0,0 +1,264 @@ +package service + +import ( + "errors" + "fmt" + "sort" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +const ( + maxMetricSamplesPerServer = 1000 + maxBackupsPerServer = 100 + maxBackupBytesPerServer = int64(4 * 1024 * 1024 * 1024) +) + +func (svc *CoreService) IngestMetricBatch(batch domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error) { + batch = domain.CopyMetricBatchIngest(batch) + if len(batch.Samples) == 0 || len(batch.Samples) > 256 { + return domain.MetricBatchIngestResult{}, validationError("metric batch must contain between 1 and 256 samples") + } + if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { + return domain.MetricBatchIngestResult{}, err + } + latest := svc.now() + for index, sample := range batch.Samples { + instance, err := svc.store.ServerInstances().Get(sample.ServerInstanceID) + if err != nil { + return domain.MetricBatchIngestResult{}, err + } + if instance.RunEndpointID != batch.RunEndpointID { + return domain.MetricBatchIngestResult{}, validationError("metric sample server must belong to runEndpointId") + } + sample.RunEndpointID = batch.RunEndpointID + if sample.ID == "" { + sample.ID = fmt.Sprintf("metric:%s:%d:%d", sample.ServerInstanceID, sample.CollectedAt.UnixNano(), index) + } + if sample.CollectedAt.IsZero() { + sample.CollectedAt = svc.now() + } + if err := validator.ValidateMetricSample(sample); err != nil { + return domain.MetricBatchIngestResult{}, err + } + if err := svc.store.MetricSamples().Create(sample); err != nil { + if !errors.Is(err, repo.ErrDuplicate) { + return domain.MetricBatchIngestResult{}, err + } + existing, getErr := svc.store.MetricSamples().Get(sample.ID) + if getErr != nil || existing.ServerInstanceID != sample.ServerInstanceID || existing.CollectedAt != sample.CollectedAt { + return domain.MetricBatchIngestResult{}, validationError("metric sample id conflicts with persisted sample") + } + } + if sample.CollectedAt.After(latest) { + latest = sample.CollectedAt + } + if err := svc.pruneMetricSamples(sample.ServerInstanceID); err != nil { + return domain.MetricBatchIngestResult{}, err + } + } + return domain.MetricBatchIngestResult{Accepted: true, AcceptedCount: len(batch.Samples), LatestAt: latest, ServerTime: svc.now()}, nil +} + +func (svc *CoreService) ListMetricSamplesForSession(sessionID string, filter domain.MetricSampleFilter) ([]domain.MetricSample, error) { + if err := validator.ValidateMetricSampleFilter(filter); err != nil { + return nil, err + } + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + instance, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID) + if err != nil { + return nil, err + } + items, err := svc.store.MetricSamples().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) }) + limit := filter.Limit + if limit == 0 { + limit = 100 + } + if len(items) > limit { + items = items[len(items)-limit:] + } + for _, sample := range items { + if sample.ServerInstanceID != instance.ID || sample.RunEndpointID != instance.RunEndpointID { + return nil, ErrForbidden + } + } + return domain.CopyMetricSamples(items), nil +} + +func (svc *CoreService) CreateBackupForSession(sessionID string, record domain.BackupRecord) (domain.BackupRecord, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID) + if err != nil { + return domain.BackupRecord{}, err + } + artifact, err := svc.store.Artifacts().Get(record.ArtifactID) + if err != nil { + return domain.BackupRecord{}, err + } + if err := svc.validateBackupArtifactOwner(instance, artifact); err != nil { + return domain.BackupRecord{}, err + } + stamp := svc.now() + if record.ID == "" { + record.ID = fmt.Sprintf("backup:%s:%d", instance.ID, stamp.UnixNano()) + } + if record.State == "" { + record.State = domain.BackupStatePending + } + if record.Checksum == "" { + record.Checksum = artifact.Checksum + } + if record.SizeBytes == 0 { + record.SizeBytes = artifact.SizeBytes + } + if record.CreatedAt.IsZero() { + record.CreatedAt = stamp + } + record.UpdatedAt = stamp + if record.State == domain.BackupStateAvailable && artifact.State != domain.ArtifactStateAvailable { + return domain.BackupRecord{}, validationError("backup artifact must be available") + } + if err := validator.ValidateBackupRecord(record); err != nil { + return domain.BackupRecord{}, err + } + if err := svc.store.Backups().Create(record); err != nil { + return domain.BackupRecord{}, err + } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.BackupRecord{}, err + } + if err := svc.recordAuditEvent(user.ID, "backup.create", "server-instance", instance.ID, domain.AuditResultQueued, "created bounded backup record with artifact checksum"); err != nil { + return domain.BackupRecord{}, err + } + if err := svc.pruneBackups(instance.ID); err != nil { + return domain.BackupRecord{}, err + } + return domain.CopyBackupRecord(record), nil +} + +func (svc *CoreService) GetBackupForSession(sessionID string, backupID string) (domain.BackupRecord, error) { + record, err := svc.store.Backups().Get(strings.TrimSpace(backupID)) + if err != nil { + return domain.BackupRecord{}, err + } + if _, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID); err != nil { + return domain.BackupRecord{}, err + } + return domain.CopyBackupRecord(record), nil +} + +func (svc *CoreService) ListBackupsForSession(sessionID string, filter domain.BackupFilter) ([]domain.BackupRecord, error) { + if err := validator.ValidateBackupFilter(filter); err != nil { + return nil, err + } + if strings.TrimSpace(filter.ServerInstanceID) == "" { + return nil, validationError("serverInstanceId is required") + } + if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + items, err := svc.store.Backups().List(filter) + if err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) }) + if len(items) > validator.MaxBackupRecordsPerQuery { + items = items[:validator.MaxBackupRecordsPerQuery] + } + return domain.CopyBackupRecords(items), nil +} + +func (svc *CoreService) RecoverIncompleteBackups() error { + items, err := svc.store.Backups().List(domain.BackupFilter{State: domain.BackupStatePending}) + if err != nil { + return err + } + for _, record := range items { + record.State = domain.BackupStateFailed + record.RecoveryStatus = "recoverable-after-interrupted-transfer" + record.UpdatedAt = svc.now() + if err := svc.store.Backups().Update(record); err != nil { + return err + } + if err := svc.recordAuditEvent("platform-recovery", "backup.recover", "server-instance", record.ServerInstanceID, domain.AuditResultFailed, "marked interrupted backup recoverable without exposing storage details"); err != nil { + return err + } + } + return nil +} + +func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error { + items, err := svc.store.MetricSamples().List(domain.MetricSampleFilter{ServerInstanceID: serverInstanceID}) + if err != nil || len(items) <= maxMetricSamplesPerServer { + return err + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) }) + for _, sample := range items[:len(items)-maxMetricSamplesPerServer] { + if err := svc.store.MetricSamples().Delete(sample.ID); err != nil { + return err + } + } + return svc.recordAuditEvent("platform-retention", "metrics.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "pruned oldest metric samples to bounded retention") +} + +func (svc *CoreService) pruneBackups(serverInstanceID string) error { + items, err := svc.store.Backups().List(domain.BackupFilter{ServerInstanceID: serverInstanceID}) + if err != nil { + return err + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) + total := int64(0) + for _, record := range items { + if record.State != domain.BackupStateExpired { + total += record.SizeBytes + } + } + pruned := false + for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer { + record := items[0] + items = items[1:] + if record.State != domain.BackupStateExpired { + total -= record.SizeBytes + } + record.State = domain.BackupStateExpired + record.RecoveryStatus = "retention-expired" + record.UpdatedAt = svc.now() + if err := svc.store.Backups().Update(record); err != nil { + return err + } + pruned = true + } + if pruned { + return svc.recordAuditEvent("platform-retention", "backup.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "expired oldest backup records to bounded retention") + } + return nil +} + +func (svc *CoreService) validateBackupArtifactOwner(instance domain.ServerInstance, artifact domain.Artifact) error { + switch artifact.OwnerKind { + case domain.ArtifactOwnerKindServerInstance: + if artifact.OwnerID != instance.ID { + return ErrForbidden + } + case domain.ArtifactOwnerKindJob: + job, err := svc.store.Jobs().Get(artifact.OwnerID) + if err != nil { + return err + } + if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID { + return ErrForbidden + } + default: + return ErrForbidden + } + return nil +} diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go new file mode 100644 index 0000000..dd81421 --- /dev/null +++ b/platform/service/remote_adapters.go @@ -0,0 +1,174 @@ +package service + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +func (svc *CoreService) ListRemoteAdapterDeclarationsForSession(sessionID string, serverInstanceID string) ([]domain.RemoteAdapterDeclaration, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return nil, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return nil, err + } + endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if err != nil { + return nil, err + } + if !plugin.Permissions.RemoteAccess { + return []domain.RemoteAdapterDeclaration{}, nil + } + declarations := make([]domain.RemoteAdapterDeclaration, 0, len(plugin.RuntimeProfiles.TransportProfiles)) + for _, profile := range plugin.RuntimeProfiles.TransportProfiles { + capabilities := intersectRemoteCapabilities(profile.Capabilities, plugin.RemoteAccess.RunCapabilities, endpoint.Capabilities) + if len(capabilities) == 0 || strings.TrimSpace(profile.TargetKey) == "" { + continue + } + declaration := domain.RemoteAdapterDeclaration{Key: profile.Key, Kind: remoteAdapterKind(profile.Kind), TargetKeys: []string{profile.TargetKey}, Capabilities: capabilities, TimeoutSeconds: 30, MaxAttempts: 3} + if err := validator.ValidateRemoteAdapterDeclaration(declaration); err != nil { + return nil, err + } + declarations = append(declarations, declaration) + } + return domain.CopyRemoteAdapterDeclarations(declarations), nil +} + +func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) { + request = domain.CopyRemoteAdapterRequest(request) + if err := validator.ValidateRemoteAdapterRequest(request); err != nil { + return domain.RemoteAdapterResult{}, err + } + instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return domain.RemoteAdapterResult{}, err + } + declarations, err := svc.ListRemoteAdapterDeclarationsForSession(sessionID, instance.ID) + if err != nil { + return domain.RemoteAdapterResult{}, err + } + var selected domain.RemoteAdapterDeclaration + for _, declaration := range declarations { + if declaration.Key == request.DeclarationKey && containsString(declaration.TargetKeys, request.TargetKey) && containsString(declaration.Capabilities, request.Capability) { + selected = declaration + break + } + } + if selected.Key == "" { + plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID) + endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) + if pluginErr == nil && endpointErr == nil && plugin.Permissions.RemoteAccess && containsString(plugin.RemoteAccess.RunCapabilities, request.Capability) && containsString(endpoint.Capabilities, request.Capability) { + selected = domain.RemoteAdapterDeclaration{Key: "legacy-" + string(remoteAdapterKindForCapability(request.Capability)), Kind: remoteAdapterKindForCapability(request.Capability), TargetKeys: []string{request.TargetKey}, Capabilities: []string{request.Capability}, TimeoutSeconds: 30, MaxAttempts: 3} + } + if selected.Key == "" { + user, _ := svc.GetCurrentUser(sessionID) + _ = svc.recordAuditEvent(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultDenied, "remote adapter declaration, target, or capability was not approved") + return domain.RemoteAdapterResult{}, ErrForbidden + } + } + timeout := request.TimeoutSeconds + if timeout == 0 { + timeout = selected.TimeoutSeconds + } + attempts := request.MaxAttempts + if attempts == 0 { + attempts = selected.MaxAttempts + } + if timeout > selected.TimeoutSeconds || attempts > selected.MaxAttempts { + return domain.RemoteAdapterResult{}, validationError("remote adapter timeout or retry exceeds declaration") + } + job := domain.Job{ + ID: jobIDFromParts("job-remote-adapter", instance.ID, request.IdempotencyKey), + ServerInstanceID: instance.ID, + RunEndpointID: instance.RunEndpointID, + Capability: request.Capability, + TargetKey: request.TargetKey, + InputRef: fmt.Sprintf("input://remote-adapters/%s/%s", instance.ID, request.DeclarationKey), + IdempotencyKey: request.IdempotencyKey, + Progress: domain.JobProgress{Percent: 0, Message: "scoped remote adapter queued"}, + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: attempts, InitialBackoffSeconds: 2, MaxBackoffSeconds: 30}, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: selected.Key, RemoteAdapterKind: string(selected.Kind), TimeoutSeconds: timeout}, + } + created, err := svc.CreateJob(job) + if err != nil { + return domain.RemoteAdapterResult{}, err + } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.RemoteAdapterResult{}, err + } + auditID, err := svc.recordAuditEventWithID(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultQueued, "authorized declared remote adapter target with bounded timeout and retry") + if err != nil { + return domain.RemoteAdapterResult{}, err + } + return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID, AuditEventID: auditID}, nil +} + +func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string { + result := make([]string, 0, len(profile)) + for _, capability := range profile { + if isRemoteAdapterCapability(capability) && containsString(declared, capability) && containsString(endpoint, capability) { + result = append(result, capability) + } + } + return result +} + +func isRemoteAdapterCapability(capability string) bool { + switch capability { + case domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite, + domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, + domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, + domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, + domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, + domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand: + return true + default: + return false + } +} + +func remoteAdapterKind(kind string) domain.RemoteAdapterKind { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "ftp": + return domain.RemoteAdapterFTP + case "rsync": + return domain.RemoteAdapterRsync + case "file": + return domain.RemoteAdapterRunFile + case "process": + return domain.RemoteAdapterRunProcess + case "sqlite", "mysql", "database": + return domain.RemoteAdapterDatabase + case "rcon": + return domain.RemoteAdapterRCON + default: + return domain.RemoteAdapterKind(strings.ToLower(strings.TrimSpace(kind))) + } +} + +func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind { + switch capability { + case domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite: + return domain.RemoteAdapterFTP + case domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite: + return domain.RemoteAdapterRsync + case domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite: + return domain.RemoteAdapterRunFile + case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop: + return domain.RemoteAdapterRunProcess + case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery: + return domain.RemoteAdapterDatabase + case domain.JobCapabilityRemoteRunRCONCommand: + return domain.RemoteAdapterRCON + case domain.JobCapabilityRemoteRunLogsTransfer: + return domain.RemoteAdapterKind("log-transfer") + default: + return "" + } +} diff --git a/platform/service/resource_authorization.go b/platform/service/resource_authorization.go new file mode 100644 index 0000000..ab6526c --- /dev/null +++ b/platform/service/resource_authorization.go @@ -0,0 +1,161 @@ +package service + +import ( + "browser.local/platform/domain" +) + +func (svc *CoreService) AuthorizePluginBridgeActionForSession(sessionID string, request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) { + if _, err := svc.GetCurrentUser(sessionID); err != nil { + return domain.PluginBridgeAuthorization{}, err + } + if request.ServerInstanceID != "" { + if _, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID); err != nil { + return domain.PluginBridgeAuthorization{}, err + } + } + return svc.AuthorizePluginBridgeAction(request) +} + +func (svc *CoreService) GetJobForSession(sessionID string, id string) (domain.Job, error) { + job, err := svc.store.Jobs().Get(id) + if err != nil { + return domain.Job{}, err + } + if err := svc.authorizeJobAccess(sessionID, job); err != nil { + return domain.Job{}, err + } + return domain.CopyJob(job), nil +} + +func (svc *CoreService) ListJobsForSession(sessionID string, filter domain.JobFilter) ([]domain.Job, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return nil, err + } + if filter.ServerInstanceID != "" { + instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID) + if err != nil { + return nil, err + } + if !canAccessServer(user, instance) { + return nil, ErrForbidden + } + } + jobs, err := svc.store.Jobs().List(filter) + if err != nil { + return nil, err + } + if isPlatformAdmin(user) { + return jobs, nil + } + visible := make([]domain.Job, 0, len(jobs)) + for _, job := range jobs { + if job.ServerInstanceID == "" { + continue + } + instance, getErr := svc.store.ServerInstances().Get(job.ServerInstanceID) + if getErr == nil && canAccessServer(user, instance) { + visible = append(visible, domain.CopyJob(job)) + } + } + return visible, nil +} + +func (svc *CoreService) RequestRunJobCancelForSession(sessionID string, request domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) { + job, err := svc.GetJobForSession(sessionID, request.JobID) + if err != nil { + return domain.RunJobCancelRequestResult{}, err + } + request.JobID = job.ID + return svc.RequestRunJobCancel(request) +} + +func (svc *CoreService) ListArtifactsForSession(sessionID string, filter domain.ArtifactFilter) ([]domain.Artifact, error) { + if _, err := svc.GetCurrentUser(sessionID); err != nil { + return nil, err + } + artifacts, err := svc.store.Artifacts().List(filter) + if err != nil { + return nil, err + } + visible := make([]domain.Artifact, 0, len(artifacts)) + for _, artifact := range artifacts { + if err := svc.authorizeArtifactAccess(sessionID, artifact); err == nil { + visible = append(visible, domain.CopyArtifact(artifact)) + } + } + if filter.OwnerID != "" && len(artifacts) > 0 && len(visible) == 0 { + return nil, ErrForbidden + } + return visible, nil +} + +func (svc *CoreService) GetLogStreamForSession(sessionID string, id string) (domain.LogStream, error) { + stream, err := svc.store.LogStreams().Get(id) + if err != nil { + return domain.LogStream{}, err + } + if _, err := svc.GetServerInstanceForSession(sessionID, stream.ServerInstanceID); err != nil { + return domain.LogStream{}, err + } + return domain.CopyLogStream(stream), nil +} + +func (svc *CoreService) ListLogStreamsForSession(sessionID string, filter domain.LogStreamFilter) ([]domain.LogStream, error) { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return nil, err + } + if filter.ServerInstanceID != "" { + instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID) + if err != nil { + return nil, err + } + if !canAccessServer(user, instance) { + return nil, ErrForbidden + } + } + streams, err := svc.store.LogStreams().List(filter) + if err != nil { + return nil, err + } + if isPlatformAdmin(user) { + return streams, nil + } + visible := make([]domain.LogStream, 0, len(streams)) + for _, stream := range streams { + instance, getErr := svc.store.ServerInstances().Get(stream.ServerInstanceID) + if getErr == nil && canAccessServer(user, instance) { + visible = append(visible, domain.CopyLogStream(stream)) + } + } + return visible, nil +} + +func (svc *CoreService) QueryLogStreamForSession(sessionID string, query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) { + if _, err := svc.GetLogStreamForSession(sessionID, query.LogStreamID); err != nil { + return domain.LogStreamCursorResult{}, err + } + return svc.QueryLogStream(query) +} + +func (svc *CoreService) authorizeJobAccess(sessionID string, job domain.Job) error { + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return err + } + if job.ServerInstanceID == "" { + if !isPlatformAdmin(user) { + return ErrForbidden + } + return nil + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + if !canAccessServer(user, instance) { + return ErrForbidden + } + return nil +} diff --git a/platform/service/resources.go b/platform/service/resources.go index 35489b9..b17797f 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -31,6 +31,7 @@ type Core interface { RegisterUser(domain.UserRegistration) (domain.AuthSession, error) LoginUser(domain.UserLogin) (domain.AuthSession, error) LogoutUser(string) error + RotateUserSession(string) (domain.AuthSession, error) GetCurrentUser(string) (domain.User, error) UpdateCurrentUserProfile(string, domain.UserProfile) (domain.User, error) UpdateCurrentUserTheme(string, domain.UserThemePreference) (domain.UserThemePreference, error) @@ -50,12 +51,14 @@ type Core interface { GetMarketplacePlugin(string) (domain.PluginMarketplacePlugin, error) SetMarketplacePluginState(string, domain.PluginMarketplaceStateAction) (domain.PluginMarketplacePlugin, error) AuthorizePluginBridgeAction(domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) + AuthorizePluginBridgeActionForSession(string, domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) ExecutePluginBridgeAction(string, domain.PluginBridgeExecuteRequest) (domain.PluginBridgeExecuteResponse, error) CreateRunEndpoint(domain.RunEndpoint) (domain.RunEndpoint, error) GetRunEndpoint(string) (domain.RunEndpoint, error) ListRunEndpoints(domain.RunEndpointFilter) ([]domain.RunEndpoint, error) RegisterRunHello(domain.RunControlHello) (domain.RunControlHelloResult, error) AcceptRunHeartbeat(domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) + AuthorizeRunRequestSignature(domain.RunRequestSignature) error CreateServerInstance(domain.ServerInstance) (domain.ServerInstance, error) CreateServerInstanceForSession(string, domain.ServerInstance) (domain.ServerInstance, error) CreateServerInstanceWorkflow(domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) @@ -64,6 +67,7 @@ type Core interface { StartServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstance(domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) StopServerInstanceForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) + QueryServerInstanceProcessForSession(string, domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) GetServerInstance(string) (domain.ServerInstance, error) GetServerInstanceForSession(string, string) (domain.ServerInstance, error) UpdateServerInstanceForSession(string, string, domain.ServerInstanceUpdate) (domain.ServerInstance, error) @@ -75,6 +79,13 @@ type Core interface { ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error) GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) ListServerMetricsForSession(string) ([]domain.ServerMetrics, error) + IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error) + ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error) + CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error) + GetBackupForSession(string, string) (domain.BackupRecord, error) + ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error) + ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error) + RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) @@ -82,28 +93,53 @@ type Core interface { CreateJob(domain.Job) (domain.Job, error) GetJob(string) (domain.Job, error) ListJobs(domain.JobFilter) ([]domain.Job, error) + GetJobForSession(string, string) (domain.Job, error) + ListJobsForSession(string, domain.JobFilter) ([]domain.Job, error) + RequestRunJobCancelForSession(string, domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) ClaimRunJob(domain.RunJobClaim) (domain.RunJobClaimResult, error) AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) + GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) + GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) + ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) + ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error) CreateArtifact(domain.Artifact) (domain.Artifact, error) GetArtifact(string) (domain.Artifact, error) ListArtifacts(domain.ArtifactFilter) ([]domain.Artifact, error) + ListArtifactsForSession(string, domain.ArtifactFilter) ([]domain.Artifact, error) GetArtifactForSession(string, string) (domain.Artifact, error) OpenArtifactDownloadForSession(string, domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) ReadArtifactContentForSession(string, domain.ArtifactContentRequest) (domain.ArtifactContent, error) GetServerRuntimeActionsForSession(string, string) (domain.ServerRuntimeActions, error) + GetServerRuntimeBindingForSession(string, string) (domain.RuntimeBindingView, error) + UpdateServerRuntimeBindingForSession(string, string, domain.RuntimeBindingUpdate) (domain.RuntimeBindingView, error) GenerateRunDistributionForSession(string, domain.RunDistributionGenerateRequest) (domain.RunDistribution, error) GenerateClientManagerDistributionForSession(string, domain.ClientManagerBuildRequest) (domain.ClientManagerDistribution, error) OpenLatestRunDistributionDownloadForSession(string, string) (domain.ArtifactDownloadReference, error) OpenLatestClientManagerDistributionDownloadForSession(string, string, string) (domain.ArtifactDownloadReference, error) ResetComponentKeyForSession(string, domain.ComponentKeyResetRequest) (domain.EncryptedComponentKey, error) AuthenticateComponent(domain.ComponentAuthenticationRequest) (domain.ComponentAuthenticationResult, error) + DeployClientManagerForSession(string, domain.ClientManagerDeployRequest) (domain.ClientManagerLifecycleView, error) + ControlClientManagerForSession(string, domain.ClientManagerControlRequest) (domain.ClientManagerLifecycleView, error) + UpdateClientManagerForSession(string, domain.ClientManagerUpdateRequest) (domain.ClientManagerLifecycleView, error) + UninstallClientManagerForSession(string, domain.ClientManagerUninstallRequest) (domain.ClientManagerLifecycleView, error) + RetryClientManagerLifecycleForSession(string, domain.ClientManagerRetryRequest) (domain.ClientManagerLifecycleView, error) + RevokeClientManagerSessionForSession(string, domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) + GetClientManagerLifecycleForSession(string, string, string) (domain.ClientManagerLifecycleView, error) + ListClientManagerLifecyclesForSession(string, string) ([]domain.ClientManagerLifecycleView, error) + GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest) (domain.ClientManagerLifecycleInput, error) + ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) + RegisterClientManager(domain.ClientManagerRegisterRequest) (domain.ClientManagerRegisterResult, error) + AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat) (domain.ClientManagerHeartbeatResult, error) + ReconcileClientManagerLifecycle() error PushRunUpdateForSession(string, domain.RunUpdateRequest) (domain.RunUpdateJob, error) + ListRunUpdateJobsForSession(string, string) ([]domain.RunUpdateJob, error) + GetDependencyCatalogForSession(string, string) (domain.DependencyCatalog, error) QueueDependencyJobForSession(string, domain.DependencyJobRequest) (domain.Job, error) QueueLogBackfillForSession(string, domain.LogBackfillRequest) (domain.Job, error) OpenArtifactTransfer(domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) @@ -113,11 +149,15 @@ type Core interface { CreateLogStream(domain.LogStream) (domain.LogStream, error) GetLogStream(string) (domain.LogStream, error) ListLogStreams(domain.LogStreamFilter) ([]domain.LogStream, error) + GetLogStreamForSession(string, string) (domain.LogStream, error) + ListLogStreamsForSession(string, domain.LogStreamFilter) ([]domain.LogStream, error) + QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) + SeedPlatformAdmin(string, string) error } type CoreService struct { @@ -129,9 +169,8 @@ type CoreService struct { runSessions map[string]domain.RunControlSession runSessionSeq uint64 jobMu sync.Mutex - jobLeases map[string]domain.RunJobLease - jobLeaseSeq uint64 logStore LogBodyStore + artifactStore ArtifactBodyStore artifactMu sync.Mutex artifactTransfers map[string]domain.ArtifactTransferSession artifactPayloads map[string][]byte @@ -139,6 +178,7 @@ type CoreService struct { auditMu sync.Mutex auditSeq uint64 aiProviderClient AIProviderClient + secretEnvelope SecretEnvelope } var _ Core = (*CoreService)(nil) @@ -159,17 +199,71 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun if logStore == nil { logStore = NewMemoryLogBodyStore() } - return &CoreService{ + artifactStore := NewMemoryArtifactBodyStore() + service := &CoreService{ store: store, now: now, authSessions: map[string]string{}, runSessions: map[string]domain.RunControlSession{}, - jobLeases: map[string]domain.RunJobLease{}, logStore: logStore, + artifactStore: artifactStore, artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactPayloads: map[string][]byte{}, aiProviderClient: MockAIProviderClient{}, + secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), } + return service +} + +func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, artifactStore ArtifactBodyStore) (*CoreService, error) { + if artifactStore == nil { + artifactStore = NewMemoryArtifactBodyStore() + } + service := newCoreServiceWithLogStore(store, logStore, func() time.Time { return time.Now().UTC() }) + service.artifactStore = artifactStore + sessions, err := artifactStore.LoadTransfers() + if err != nil { + return nil, err + } + for _, session := range sessions { + service.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session) + } + if err := service.recoverLogCursors(); err != nil { + return nil, err + } + if err := service.RecoverIncompleteBackups(); err != nil { + return nil, err + } + if err := service.ReconcileClientManagerLifecycle(); err != nil { + return nil, err + } + return service, nil +} + +func (svc *CoreService) recoverLogCursors() error { + store, ok := svc.logStore.(interface{ LatestSeq(string) (uint64, error) }) + if !ok { + return nil + } + streams, err := svc.store.LogStreams().List(domain.LogStreamFilter{}) + if err != nil { + return err + } + for _, stream := range streams { + latest, latestErr := store.LatestSeq(stream.ID) + if latestErr != nil || latest <= stream.LatestSeq { + if latestErr != nil { + return latestErr + } + continue + } + stream.LatestSeq = latest + stream.UpdatedAt = svc.now() + if err := svc.store.LogStreams().Update(stream); err != nil { + return err + } + } + return nil } func (svc *CoreService) CreateUser(user domain.User) (domain.User, error) { @@ -240,6 +334,11 @@ func (svc *CoreService) UpdateUser(id string, user domain.User) (domain.User, er if err := svc.store.Users().Update(user); err != nil { return domain.User{}, err } + if user.Status != domain.UserStatusActive { + if err := svc.revokeUserSessions(user.ID); err != nil { + return domain.User{}, err + } + } return domain.CopyUser(user), nil } @@ -282,19 +381,7 @@ func (svc *CoreService) RegisterUser(registration domain.UserRegistration) (doma return domain.AuthSession{}, err } if firstUser { - sessionID, err := randomToken() - if err != nil { - return domain.AuthSession{}, err - } - svc.authMu.Lock() - svc.authSessions[sessionID] = created.ID - svc.authMu.Unlock() - return domain.AuthSession{ - SessionID: sessionID, - User: created, - Status: "authenticated", - Message: "首个账号已创建为平台管理员。", - }, nil + return svc.issueAuthSession(created, "首个账号已创建为平台管理员。") } return domain.AuthSession{ User: created, @@ -325,27 +412,11 @@ func (svc *CoreService) LoginUser(login domain.UserLogin) (domain.AuthSession, e if matched.Status == domain.UserStatusDisabled { return domain.AuthSession{}, ErrForbidden } - sessionID, err := randomToken() - if err != nil { - return domain.AuthSession{}, err - } - svc.authMu.Lock() - svc.authSessions[sessionID] = matched.ID - svc.authMu.Unlock() - return domain.AuthSession{SessionID: sessionID, User: matched, Status: "authenticated", Message: "登录成功"}, nil + return svc.issueAuthSession(matched, "登录成功") } func (svc *CoreService) LogoutUser(sessionID string) error { - if strings.TrimSpace(sessionID) == "" { - return ErrUnauthorized - } - svc.authMu.Lock() - defer svc.authMu.Unlock() - if _, exists := svc.authSessions[sessionID]; !exists { - return ErrUnauthorized - } - delete(svc.authSessions, sessionID) - return nil + return svc.revokeAuthSession(sessionID) } func (svc *CoreService) GetCurrentUser(sessionID string) (domain.User, error) { @@ -381,7 +452,17 @@ func (svc *CoreService) UpdateCurrentUserTheme(sessionID string, preference doma } func (svc *CoreService) SeedLocalPlatformAdmin() error { - const adminEmail = "operator.local@example.test" + return svc.SeedPlatformAdmin("operator.local@example.test", "operator-local") +} + +func (svc *CoreService) SeedPlatformAdmin(email string, password string) error { + email = strings.TrimSpace(email) + if email == "" { + email = "operator.local@example.test" + } + if len([]rune(password)) < 12 { + return validationError("bootstrap admin password must be at least 12 characters") + } _, err := svc.store.Users().Get("user-admin") if err == nil { return nil @@ -392,11 +473,11 @@ func (svc *CoreService) SeedLocalPlatformAdmin() error { return svc.store.Users().Create(domain.User{ ID: "user-admin", DisplayName: "Operator", - Email: adminEmail, + Email: email, Status: domain.UserStatusActive, Roles: []string{"platform-admin"}, - PasswordHash: mustHashPassword("operator-local"), - Profile: domain.UserProfile{ContactNote: "local development admin"}, + PasswordHash: mustHashPassword(password), + Profile: domain.UserProfile{ContactNote: "bootstrap platform admin"}, CreatedAt: svc.now(), UpdatedAt: svc.now(), }) @@ -541,6 +622,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe Tags: manifest.Tags, AIPurposes: manifest.AI.Purposes, RemoteAccess: manifest.RemoteAccess, + RuntimeProfiles: manifest.RuntimeProfiles, Status: domain.GamePluginStatusInstalled, } } @@ -623,11 +705,11 @@ func (svc *CoreService) ExecutePluginBridgeAction(sessionID string, request doma case domain.PluginBridgeActionFilesRequest: base = svc.executeBridgeFileRequest(sessionID, base, request) case domain.PluginBridgeActionRemoteAccessRequest: - base = svc.executeBridgeRemoteAccessRequest(base, plugin, instance, request.Payload) + base = svc.executeBridgeRemoteAccessRequest(sessionID, base, plugin, instance, request.Payload) case domain.PluginBridgeActionRunDistribution: base = svc.executeBridgeRunDistribution(sessionID, base, request) case domain.PluginBridgeActionDependenciesRequest: - base = svc.executeBridgeDependenciesRequest(base, plugin, instance, request.Payload) + base = svc.executeBridgeDependenciesRequest(sessionID, base, plugin, instance, request.Payload) case domain.PluginBridgeActionLogsBackfillRequest: base = svc.executeBridgeLogsBackfillRequest(base, plugin, instance, request.Payload) case domain.PluginBridgeActionClientManager: @@ -843,39 +925,44 @@ func (svc *CoreService) executeBridgeFileRequest(sessionID string, base domain.P return base } -func (svc *CoreService) executeBridgeRemoteAccessRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { +func (svc *CoreService) executeBridgeRemoteAccessRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { capability := strings.TrimSpace(payload["capability"]) if capability == "" { base.Status = "error" base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "capability is required"} return base } - if !containsString(plugin.RequiredRunCapabilities, capability) { + if !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "requested remote capability is not declared by plugin"} return base } - job := domain.Job{ - ID: jobIDFromParts("job-remote", base.RequestID, capability), - ServerInstanceID: instance.ID, - RunEndpointID: instance.RunEndpointID, - Capability: capability, - TargetKey: payload["targetKey"], - InputRef: payload["inputRef"], - IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), - Progress: domain.JobProgress{Percent: 0, Message: "remote access job queued"}, + declarationKey := strings.TrimSpace(payload["declarationKey"]) + if declarationKey == "" { + for _, profile := range plugin.RuntimeProfiles.TransportProfiles { + if profile.TargetKey == payload["targetKey"] && containsString(profile.Capabilities, capability) { + declarationKey = profile.Key + break + } + } } - created, err := svc.CreateJob(job) + if declarationKey == "" { + declarationKey = "legacy-" + string(remoteAdapterKindForCapability(capability)) + } + timeoutSeconds, _ := strconv.Atoi(payload["timeoutSeconds"]) + maxAttempts, _ := strconv.Atoi(payload["maxAttempts"]) + result, err := svc.RequestRemoteAdapterForSession(sessionID, domain.RemoteAdapterRequest{ServerInstanceID: instance.ID, DeclarationKey: declarationKey, TargetKey: payload["targetKey"], Capability: capability, TimeoutSeconds: timeoutSeconds, MaxAttempts: maxAttempts, IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID)}) if err != nil { return bridgeExecutionError(base, err) } base.Status = "queued" base.Result = map[string]string{ - "jobId": created.ID, - "state": string(created.State), - "capability": created.Capability, - "targetKey": created.TargetKey, - "serverInstanceId": created.ServerInstanceID, + "jobId": result.RequestID, + "state": result.Status, + "capability": capability, + "targetKey": result.TargetKey, + "serverInstanceId": result.ServerInstanceID, + "adapterKind": string(result.Kind), } return base } @@ -896,60 +983,116 @@ func (svc *CoreService) executeBridgeRunDistribution(sessionID string, base doma "artifactId": distribution.ArtifactID, "checksum": distribution.Checksum, "keyGeneration": strconv.Itoa(distribution.KeyGeneration), - "secretRef": distribution.SecretRef, "status": string(distribution.Status), } return base } func (svc *CoreService) executeBridgeClientManager(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { - distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{ - ServerInstanceID: request.ServerInstanceID, - ProfileKey: request.Payload["profileKey"], - TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"), - TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"), - RepositoryURL: request.Payload["repositoryUrl"], - SourceRevision: request.Payload["sourceRevision"], - IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), - }) + profileKey := request.Payload["profileKey"] + operation := defaultBridgeValue(request.Payload["operation"], "status") + idempotencyKey := defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID) + generation, _ := strconv.Atoi(request.Payload["expectedDeploymentGeneration"]) + instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) + if err != nil { + return bridgeExecutionError(base, err) + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return bridgeExecutionError(base, err) + } + profile, err := findRuntimeClientManagerProfile(plugin, profileKey) + if err != nil { + return bridgeExecutionError(base, ErrForbidden) + } + var view domain.ClientManagerLifecycleView + switch operation { + case "generate": + distribution, err := svc.GenerateClientManagerDistributionForSession(sessionID, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, TargetOS: defaultBridgeValue(request.Payload["targetOs"], "windows"), TargetArch: defaultBridgeValue(request.Payload["targetArch"], "amd64"), RepositoryURL: profile.RepositoryURL, SourceRevision: clientManagerProfileRevision(profile), IdempotencyKey: idempotencyKey}) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "queued" + base.Result = map[string]string{"distributionId": distribution.ID, "buildJobId": distribution.BuildJobID, "artifactId": distribution.ArtifactID, "checksum": distribution.Checksum, "keyGeneration": strconv.Itoa(distribution.KeyGeneration), "version": distribution.Version, "status": string(distribution.Status)} + return base + case "download": + reference, err := svc.OpenLatestClientManagerDistributionDownloadForSession(sessionID, instance.ID, profileKey) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "ok" + base.Result = map[string]string{"artifactId": reference.ArtifactID, "downloadUrl": reference.DownloadURL, "checksum": reference.Checksum, "sizeBytes": strconv.FormatInt(reference.SizeBytes, 10), "expiresAt": reference.ExpiresAt.Format(time.RFC3339), "rangeSupported": strconv.FormatBool(reference.RangeSupported), "chunkSizeBytes": strconv.Itoa(reference.ChunkSizeBytes)} + return base + case "reset-key": + key, err := svc.ResetComponentKeyForSession(sessionID, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: profileKey}) + if err != nil { + return bridgeExecutionError(base, err) + } + base.Status = "ok" + base.Result = map[string]string{"profileKey": profileKey, "keyGeneration": strconv.Itoa(key.Generation), "status": string(key.Status), "requiresRedeploy": "true"} + return base + case "status": + view, err = svc.GetClientManagerLifecycleForSession(sessionID, instance.ID, profileKey) + case "deploy": + distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"]) + if resolveErr != nil { + return bridgeExecutionError(base, resolveErr) + } + view, err = svc.DeployClientManagerForSession(sessionID, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey}) + case "start", "stop", "restart", "rollback": + view, err = svc.ControlClientManagerForSession(sessionID, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Operation: domain.ClientManagerLifecycleOperation(operation), ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey}) + case "update": + distributionID, resolveErr := svc.resolveClientManagerDistributionID(instance.ID, profileKey, request.Payload["artifactId"]) + if resolveErr != nil { + return bridgeExecutionError(base, resolveErr) + } + view, err = svc.UpdateClientManagerForSession(sessionID, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, DistributionID: distributionID, ExpectedDeploymentGeneration: generation, Approved: true, IdempotencyKey: idempotencyKey}) + case "retry": + view, err = svc.RetryClientManagerLifecycleForSession(sessionID, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, IdempotencyKey: idempotencyKey}) + case "revoke-session": + view, err = svc.RevokeClientManagerSessionForSession(sessionID, domain.ClientManagerRevokeSessionRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, Reason: "plugin bridge operator request"}) + case "uninstall": + view, err = svc.UninstallClientManagerForSession(sessionID, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: profileKey, ExpectedDeploymentGeneration: generation, Confirmed: true, IdempotencyKey: idempotencyKey}) + default: + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "invalid_client_manager_operation", Message: "client-manager operation is not supported"} + return base + } if err != nil { return bridgeExecutionError(base, err) } base.Status = "ok" - base.Result = map[string]string{ - "distributionId": distribution.ID, - "buildJobId": distribution.BuildJobID, - "artifactId": distribution.ArtifactID, - "checksum": distribution.Checksum, - "keyGeneration": strconv.Itoa(distribution.KeyGeneration), - "secretRef": distribution.SecretRef, - "status": string(distribution.Status), + if view.Job.ID != "" && !isTerminalJobState(view.Job.State) { + base.Status = "queued" } + base.Result = safeClientManagerBridgeResult(view) return base } -func (svc *CoreService) executeBridgeDependenciesRequest(base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { - action := defaultBridgeValue(payload["action"], "check") +func (svc *CoreService) executeBridgeDependenciesRequest(sessionID string, base domain.PluginBridgeExecuteResponse, plugin domain.GamePlugin, instance domain.ServerInstance, payload map[string]string) domain.PluginBridgeExecuteResponse { + action := defaultBridgeValue(payload["operation"], "check") capability := domain.JobCapabilityDependenciesCheck - message := "dependency check queued" if action == "install" { capability = domain.JobCapabilityDependenciesInstall - message = "dependency install queued" + } else if action != "check" { + base.Status = "denied" + base.Error = &domain.PluginBridgeSafeError{Code: "invalid_dependency_operation", Message: "dependency operation must be check or install"} + return base } if !containsString(plugin.RequiredRunCapabilities, capability) { base.Status = "denied" base.Error = &domain.PluginBridgeSafeError{Code: "capability_denied", Message: "dependency capability is not declared by plugin"} return base } - job, err := svc.CreateJob(domain.Job{ - ID: jobIDFromParts("job-dependencies", base.RequestID, capability), + job, err := svc.QueueDependencyJobForSession(sessionID, domain.DependencyJobRequest{ ServerInstanceID: instance.ID, - RunEndpointID: instance.RunEndpointID, - Capability: capability, - TargetKey: defaultBridgeValue(payload["probeKey"], "dependencies/default"), - InputRef: payload["inputRef"], + ProbeKey: payload["probeKey"], + InstallPlanKey: payload["planKey"], + PlanDigest: payload["planDigest"], + TargetOS: payload["targetOS"], + TargetArch: payload["targetArch"], IdempotencyKey: defaultBridgeValue(payload["idempotencyKey"], base.RequestID), - Progress: domain.JobProgress{Percent: 0, Message: message}, + Install: action == "install", }) if err != nil { return bridgeExecutionError(base, err) @@ -1136,6 +1279,7 @@ func marketplacePluginFromGamePlugin(plugin domain.GamePlugin) domain.PluginMark Tags: plugin.Tags, AIPurposes: plugin.AIPurposes, RemoteAccess: plugin.RemoteAccess, + RuntimeProfiles: plugin.RuntimeProfiles, ValidationViolations: plugin.ValidationViolations, Status: plugin.Status, Source: "platform-registry", @@ -1390,11 +1534,22 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan Key: "server.properties", Source: "platform-derived", UpdatedAt: instance.UpdatedAt, + Checksum: instance.ConfigChecksum, } if config.UpdatedAt.IsZero() { config.UpdatedAt = svc.now() } - config.Content = buildLogicalServerConfig(instance) + config.Key = instance.ConfigKey + if config.Key == "" { + config.Key = "server.properties" + } + config.Content = instance.ConfigContent + if config.Content == "" { + config.Content = buildLogicalServerConfig(instance) + } + if config.Checksum == "" { + config.Checksum = validator.BytesChecksum([]byte(config.Content)) + } if err := validator.ValidateServerConfig(config); err != nil { return domain.ServerConfig{}, err } @@ -1415,12 +1570,16 @@ func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, req if config.ConfigVersion != request.ExpectedConfigVersion { return domain.ServerConfigDiffPreview{}, validationError("expectedConfigVersion must match server instance") } + if request.ExpectedChecksum != "" && config.Checksum != request.ExpectedChecksum { + return domain.ServerConfigDiffPreview{}, validationError("expectedChecksum must match server config") + } if config.Key != request.Key { return domain.ServerConfigDiffPreview{}, validationError("key must match server config") } preview := domain.ServerConfigDiffPreview{ ServerInstanceID: request.ServerInstanceID, ConfigVersion: config.ConfigVersion, + Checksum: config.Checksum, Key: request.Key, CurrentContent: config.Content, ProposedContent: request.ProposedContent, @@ -1446,6 +1605,7 @@ func (svc *CoreService) ApproveServerConfigWriteForSession(sessionID string, app preview, err := svc.PreviewServerConfigWriteForSession(sessionID, domain.ServerConfigDiffRequest{ ServerInstanceID: approval.ServerInstanceID, ExpectedConfigVersion: approval.ExpectedConfigVersion, + ExpectedChecksum: approval.ExpectedChecksum, Key: approval.Key, ProposedContent: approval.ProposedContent, ProposedContentInputRef: approval.ProposedContentInputRef, @@ -1460,6 +1620,13 @@ func (svc *CoreService) ApproveServerConfigWriteForSession(sessionID string, app if err != nil { return domain.ServerConfigWriteDispatch{}, err } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.ServerConfigWriteDispatch{}, err + } + if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "config.write.denied"); err != nil { + return domain.ServerConfigWriteDispatch{}, err + } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-config-write", approval.ServerInstanceID, approval.IdempotencyKey), ServerInstanceID: instance.ID, @@ -1467,6 +1634,7 @@ func (svc *CoreService) ApproveServerConfigWriteForSession(sessionID string, app Capability: domain.JobCapabilityConfigWrite, TargetKey: approval.Key, InputRef: approval.ProposedContentInputRef, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: approval.ProposedContent, ExpectedVersion: approval.ExpectedConfigVersion, ExpectedChecksum: preview.Checksum, MaxReadBytes: 64 * 1024}, IdempotencyKey: approval.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "config write queued"}, }) @@ -1487,6 +1655,29 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques if request.ExpectedConfigVersion > 0 && request.ExpectedConfigVersion != instance.ConfigVersion { return domain.FileOperationDispatchResult{}, validationError("expectedConfigVersion must match server instance") } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return domain.FileOperationDispatchResult{}, err + } + if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.operation.denied"); err != nil { + return domain.FileOperationDispatchResult{}, err + } + content := request.Content + if request.Operation == domain.FileOperationWrite && content == "" && strings.HasPrefix(request.InputRef, "artifact://") { + artifactID := strings.TrimPrefix(request.InputRef, "artifact://") + artifact, artifactErr := svc.store.Artifacts().Get(artifactID) + if artifactErr != nil { + return domain.FileOperationDispatchResult{}, artifactErr + } + if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable { + return domain.FileOperationDispatchResult{}, ErrForbidden + } + payload, payloadErr := svc.artifactPayload(artifactID) + if payloadErr != nil { + return domain.FileOperationDispatchResult{}, payloadErr + } + content = string(payload) + } if request.PluginID != "" { plugin, err := svc.store.GamePlugins().Get(request.PluginID) if err != nil { @@ -1518,6 +1709,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques Capability: capability, TargetKey: request.Key, InputRef: request.InputRef, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), Content: content, ExpectedVersion: request.ExpectedConfigVersion, ExpectedChecksum: request.ExpectedChecksum, MaxReadBytes: 64 * 1024}, IdempotencyKey: request.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: message}, }) @@ -1535,6 +1727,14 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques }), nil } +func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string { + binding, err := svc.runtimeBindingForServer(serverInstanceID) + if err != nil { + return "default" + } + return binding.ProfileKey +} + func (svc *CoreService) metricsForServer(instance domain.ServerInstance) domain.ServerMetrics { metrics := domain.ServerMetrics{ ServerInstanceID: instance.ID, @@ -1732,6 +1932,7 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) { if job.UpdatedAt.IsZero() { job.UpdatedAt = stamp } + job = normalizeJobScheduling(job, stamp) if err := validator.ValidateJob(job); err != nil { return domain.Job{}, err } @@ -1772,11 +1973,22 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) { } func (svc *CoreService) GetJob(id string) (domain.Job, error) { - return svc.store.Jobs().Get(id) + job, err := svc.store.Jobs().Get(id) + if err != nil { + return domain.Job{}, err + } + return normalizeJobScheduling(job, svc.now()), nil } func (svc *CoreService) ListJobs(filter domain.JobFilter) ([]domain.Job, error) { - return svc.store.Jobs().List(filter) + jobs, err := svc.store.Jobs().List(filter) + if err != nil { + return nil, err + } + for i := range jobs { + jobs[i] = normalizeJobScheduling(jobs[i], svc.now()) + } + return jobs, nil } func (svc *CoreService) CreateArtifact(artifact domain.Artifact) (domain.Artifact, error) { @@ -1891,17 +2103,11 @@ func validationError(violation string) error { } func (svc *CoreService) userIDForSession(sessionID string) (string, error) { - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - return "", ErrUnauthorized + session, err := svc.authenticatedSession(sessionID) + if err != nil { + return "", err } - svc.authMu.Lock() - defer svc.authMu.Unlock() - userID, exists := svc.authSessions[sessionID] - if !exists { - return "", ErrUnauthorized - } - return userID, nil + return session.UserID, nil } func userIDFromEmail(email string) string { diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 2679575..20c9817 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -8,6 +8,7 @@ import ( "browser.local/platform/domain" "browser.local/platform/repo" + "browser.local/platform/validator" ) var fixedTime = time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) @@ -351,10 +352,10 @@ func TestCoreServiceScopesServerAccessAndMembership(t *testing.T) { if err != nil { t.Fatalf("create owned server: %v", err) } + createCompleteRuntimeBinding(t, svc, instance, "local") if instance.OwnerUserID != "user-owner" { t.Fatalf("expected owner to be recorded, got %+v", instance) } - ownerServers, err := svc.ListServerInstancesForSession(ownerSession, domain.ServerInstanceFilter{}) if err != nil || len(ownerServers) != 1 { t.Fatalf("expected owner server visibility, len=%d err=%v", len(ownerServers), err) @@ -513,6 +514,7 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) { if err != nil { t.Fatalf("create server: %v", err) } + createCompleteRuntimeBinding(t, svc, instance, "local") current, err := svc.GetServerConfigForSession(ownerSession, instance.ID) if err != nil { t.Fatalf("get config: %v", err) @@ -603,6 +605,51 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) { } } +func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "typed-config-owner", DisplayName: "Typed Config Owner", Email: "typed-config-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "typed-config-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Typed Config", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create typed config server: %v", err) + } + createCompleteRuntimeBinding(t, svc, instance, "local") + current, err := svc.GetServerConfigForSession(ownerSession, instance.ID) + if err != nil { + t.Fatalf("read typed config: %v", err) + } + proposed := current.Content + "motd=typed\n" + dispatch, err := svc.ApproveServerConfigWriteForSession(ownerSession, domain.ServerConfigWriteApproval{ServerInstanceID: instance.ID, ExpectedConfigVersion: current.ConfigVersion, ExpectedChecksum: current.Checksum, Key: current.Key, ProposedContent: proposed, IdempotencyKey: "typed-config-write"}) + if err != nil { + t.Fatalf("queue typed config write: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityConfigWrite) + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register typed config Run: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, Capabilities: []string{domain.JobCapabilityConfigWrite}, Capacity: domain.RunCapacity{MaxJobs: 2}}) + if err != nil || !claim.HasJob { + t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err) + } + checksum := validator.BytesChecksum([]byte(proposed)) + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), AuditSummary: "atomic compare-and-swap file write"}}); err != nil { + t.Fatalf("complete typed config job: %v", err) + } + updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID) + if err != nil || updated.Content != proposed || updated.ConfigVersion != current.ConfigVersion+1 || updated.Checksum != checksum { + t.Fatalf("expected durable typed config projection, config=%+v err=%v", updated, err) + } + stored, err := svc.GetJobForSession(ownerSession, dispatch.Job.ID) + if err != nil { + t.Fatalf("read typed config job: %v", err) + } + if stored.ExecutionResult.Content != "" || stored.ExecutionResult.Checksum != checksum || stored.ExecutionResult.Version != current.ConfigVersion+1 { + t.Fatalf("unexpected safe/private job result projection: %+v", stored.ExecutionResult) + } +} + func TestCoreServiceUpdatesUsersProfileAndTheme(t *testing.T) { svc := newTestCoreService() if _, err := svc.CreateUser(domain.User{ @@ -1145,6 +1192,7 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug Files: true, Jobs: true, }, + RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, }) if err != nil { t.Fatalf("create plugin fixture: %v", err) @@ -1164,6 +1212,22 @@ func createPluginAndRunEndpoint(t *testing.T, svc *CoreService) (domain.GamePlug return plugin, endpoint } +func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domain.ServerInstance, profileKey string) domain.RuntimeBinding { + t.Helper() + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + t.Fatalf("get plugin for runtime binding: %v", err) + } + binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: map[string]string{}}, true) + if err != nil { + t.Fatalf("build runtime binding: %v", err) + } + if err := svc.store.RuntimeBindings().Create(binding); err != nil { + t.Fatalf("create runtime binding: %v", err) + } + return binding +} + func validPluginManifestRegistration() domain.GamePluginManifestRegistration { return domain.GamePluginManifestRegistration{ ManifestRef: "artifact://manifests/game.example/0.1.0", @@ -1205,7 +1269,8 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration { BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)}, }, }, - AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}}, + AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}}, + RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, }, } } diff --git a/platform/service/runtime_bindings.go b/platform/service/runtime_bindings.go new file mode 100644 index 0000000..9d86984 --- /dev/null +++ b/platform/service/runtime_bindings.go @@ -0,0 +1,224 @@ +package service + +import ( + "errors" + "fmt" + "sort" + "strings" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +func (svc *CoreService) GetServerRuntimeBindingForSession(sessionID, serverInstanceID string) (domain.RuntimeBindingView, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return domain.RuntimeBindingView{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.RuntimeBindingView{}, err + } + binding, err := svc.runtimeBindingForServer(instance.ID) + if errors.Is(err, repo.ErrNotFound) { + return domain.RuntimeBindingView{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Status: domain.RuntimeBindingStatusIncomplete, Reason: "runtime profile is not configured"}, nil + } + if err != nil { + return domain.RuntimeBindingView{}, err + } + return runtimeBindingView(plugin, binding) +} + +func (svc *CoreService) UpdateServerRuntimeBindingForSession(sessionID, serverInstanceID string, update domain.RuntimeBindingUpdate) (domain.RuntimeBindingView, error) { + _, instance, err := svc.requireServerOwner(sessionID, serverInstanceID) + if err != nil { + return domain.RuntimeBindingView{}, err + } + existing, existingErr := svc.runtimeBindingForServer(instance.ID) + if existingErr != nil && !errors.Is(existingErr, repo.ErrNotFound) { + return domain.RuntimeBindingView{}, existingErr + } + if (instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning) && existingErr == nil || instance.State == domain.ServerInstanceStateDeleted { + return domain.RuntimeBindingView{}, validationError("runtime binding cannot be changed while the server is active") + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.RuntimeBindingView{}, err + } + binding, err := svc.buildRuntimeBinding(instance, plugin, update, false) + if err != nil { + return domain.RuntimeBindingView{}, err + } + if existingErr == nil { + binding.CreatedAt = existing.CreatedAt + if existing.ProfileKey == binding.ProfileKey { + merged := domain.CopyStringMap(existing.Bindings) + for key, value := range update.Bindings { + if strings.TrimSpace(value) == "" { + delete(merged, key) + } else { + merged[key] = value + } + } + binding, err = svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: update.ProfileKey, Bindings: merged}, false) + if err != nil { + return domain.RuntimeBindingView{}, err + } + binding.CreatedAt = existing.CreatedAt + } + if err := svc.store.RuntimeBindings().Update(binding); err != nil { + return domain.RuntimeBindingView{}, err + } + } else if errors.Is(existingErr, repo.ErrNotFound) { + if err := svc.store.RuntimeBindings().Create(binding); err != nil { + return domain.RuntimeBindingView{}, err + } + } + return runtimeBindingView(plugin, binding) +} + +func (svc *CoreService) buildRuntimeBinding(instance domain.ServerInstance, plugin domain.GamePlugin, update domain.RuntimeBindingUpdate, requireComplete bool) (domain.RuntimeBinding, error) { + update = domain.CopyRuntimeBindingUpdate(update) + profile, ok := runtimeLifecycleProfile(plugin.RuntimeProfiles, update.ProfileKey) + if !ok { + return domain.RuntimeBinding{}, validationError("profileKey must reference a declared lifecycle profile") + } + stamp := svc.now() + binding := domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: profile.Key, Mode: profile.Mode, Bindings: update.Bindings, CreatedAt: stamp, UpdatedAt: stamp} + binding, err := normalizeRuntimeBinding(plugin, binding) + if err != nil { + return domain.RuntimeBinding{}, err + } + if requireComplete && binding.Status != domain.RuntimeBindingStatusComplete { + return domain.RuntimeBinding{}, validationError("missing runtime bindings: " + strings.Join(binding.MissingKeys, ", ")) + } + return binding, nil +} + +func runtimeLifecycleProfile(profiles domain.GamePluginRuntimeProfiles, key string) (domain.RuntimeLifecycleProfile, bool) { + for _, profile := range profiles.LifecycleProfiles { + if profile.Key == key { + return profile, true + } + } + return domain.RuntimeLifecycleProfile{}, false +} + +func runtimeBindingKeys(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) ([]string, map[string]struct{}) { + requiredSet := map[string]struct{}{} + allowed := map[string]struct{}{} + add := func(key string, required bool) { + if key == "" { + return + } + allowed[key] = struct{}{} + if required { + requiredSet[key] = struct{}{} + } + } + for _, probe := range profiles.Discovery { + add(probe.TargetKey, probe.Required) + } + for _, probe := range profiles.DependencyProbes { + add(probe.TargetKey, probe.Required) + } + for _, source := range profiles.LogSources { + add(source.TargetKey, source.TargetKey != "") + } + for _, plan := range profiles.InstallPlans { + for _, step := range plan.Steps { + add(step.TargetKey, false) + } + } + for _, transport := range profiles.TransportProfiles { + if containsString(profile.TransportKeys, transport.Key) { + if transport.TargetKey != "" { + add(transport.TargetKey, true) + } else { + add(transport.Key, true) + } + } + } + if profile.ClientManagerRef != "" { + add(profile.ClientManagerRef, true) + } + required := make([]string, 0, len(requiredSet)) + for key := range requiredSet { + required = append(required, key) + } + sort.Strings(required) + return required, allowed +} + +func normalizeRuntimeBinding(plugin domain.GamePlugin, binding domain.RuntimeBinding) (domain.RuntimeBinding, error) { + profile, _ := runtimeLifecycleProfile(plugin.RuntimeProfiles, binding.ProfileKey) + if profile.Key == "" { + return domain.RuntimeBinding{}, validationError("runtime profile is no longer declared") + } + required, allowed := runtimeBindingKeys(plugin.RuntimeProfiles, profile) + for key := range binding.Bindings { + if _, ok := allowed[key]; !ok { + return domain.RuntimeBinding{}, validationError(fmt.Sprintf("bindings.%s is not declared by runtime profile", key)) + } + } + missing := make([]string, 0) + for _, key := range required { + if strings.TrimSpace(binding.Bindings[key]) == "" { + missing = append(missing, key) + } + } + binding.Mode = profile.Mode + binding.MissingKeys = missing + binding.Status = domain.RuntimeBindingStatusComplete + if len(missing) > 0 { + binding.Status = domain.RuntimeBindingStatusIncomplete + } + if err := validator.ValidateRuntimeBinding(binding); err != nil { + return domain.RuntimeBinding{}, err + } + return binding, nil +} + +func runtimeBindingView(plugin domain.GamePlugin, binding domain.RuntimeBinding) (domain.RuntimeBindingView, error) { + binding, err := normalizeRuntimeBinding(plugin, binding) + if err != nil { + return domain.RuntimeBindingView{}, err + } + profile, _ := runtimeLifecycleProfile(plugin.RuntimeProfiles, binding.ProfileKey) + required, allowed := runtimeBindingKeys(plugin.RuntimeProfiles, profile) + requiredSet := map[string]struct{}{} + for _, key := range required { + requiredSet[key] = struct{}{} + } + keys := make([]string, 0, len(allowed)) + for key := range allowed { + keys = append(keys, key) + } + sort.Strings(keys) + items := make([]domain.RuntimeBindingKeyView, 0, len(keys)) + for _, key := range keys { + value := strings.TrimSpace(binding.Bindings[key]) + _, isRequired := requiredSet[key] + items = append(items, domain.RuntimeBindingKeyView{Key: key, Required: isRequired, Configured: value != "", Secret: strings.HasPrefix(value, "secret://")}) + } + reason := "" + if binding.Status != domain.RuntimeBindingStatusComplete { + reason = "required logical bindings are missing" + } + return domain.RuntimeBindingView{ServerInstanceID: binding.ServerInstanceID, PluginID: binding.PluginID, ProfileKey: binding.ProfileKey, Mode: binding.Mode, Configured: true, Keys: items, MissingKeys: domain.CopyStringSlice(binding.MissingKeys), Status: binding.Status, Reason: reason, CreatedAt: binding.CreatedAt, UpdatedAt: binding.UpdatedAt}, nil +} + +func (svc *CoreService) runtimeBindingForServer(serverInstanceID string) (domain.RuntimeBinding, error) { + bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: serverInstanceID}) + if err != nil { + return domain.RuntimeBinding{}, err + } + if len(bindings) == 0 { + return domain.RuntimeBinding{}, repo.ErrNotFound + } + if len(bindings) > 1 { + return domain.RuntimeBinding{}, validationError("server has multiple runtime bindings") + } + return bindings[0], nil +} diff --git a/platform/service/runtime_bindings_test.go b/platform/service/runtime_bindings_test.go new file mode 100644 index 0000000..9283540 --- /dev/null +++ b/platform/service/runtime_bindings_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" +) + +func TestRegisteredRuntimeProfilesSurviveFileStoreReload(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) + } + svc := newCoreService(store, func() time.Time { return fixedTime }) + registration := validPluginManifestRegistration() + registration.Manifest.RuntimeProfiles = requiredRuntimeProfilesFixture() + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "remote.run.rcon.command") + registered, err := svc.RegisterGamePluginManifest(registration) + if err != nil { + t.Fatalf("register manifest: %v", err) + } + if len(registered.RuntimeProfiles.LifecycleProfiles) != 1 { + t.Fatalf("expected registered profiles, got %+v", registered.RuntimeProfiles) + } + + reloaded, err := repo.NewFileStore(path) + if err != nil { + t.Fatalf("reload file store: %v", err) + } + plugin, err := reloaded.GamePlugins().Get(registration.Manifest.ID) + if err != nil { + t.Fatalf("get reloaded plugin: %v", err) + } + if plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys[0] != "rcon" || plugin.RuntimeProfiles.TransportProfiles[0].TargetKey != "rcon.password" { + t.Fatalf("runtime profiles were not preserved: %+v", plugin.RuntimeProfiles) + } +} + +func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + plugin.RuntimeProfiles = requiredRuntimeProfilesFixture() + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatalf("update plugin profiles: %v", err) + } + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-owner", DisplayName: "Runtime Owner", Email: "runtime-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-other", DisplayName: "Runtime Other", Email: "runtime-other@example.test", Roles: []string{"server-admin"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Runtime Server", State: domain.ServerInstanceStateReady}) + if err != nil { + t.Fatalf("create server: %v", err) + } + forged, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-forged-complete", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Forged Complete", State: domain.ServerInstanceStateReady}) + if err != nil { + t.Fatalf("create forged-status server: %v", err) + } + if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-" + forged.ID, ServerInstanceID: forged.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: "local", Mode: "local-process", Bindings: map[string]string{}, Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { + t.Fatalf("store forged complete binding: %v", err) + } + if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: forged.ID, ExpectedConfigVersion: forged.ConfigVersion, IdempotencyKey: "start-forged-complete"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { + t.Fatalf("expected derived missing keys to override stored complete status, got %v", err) + } + + view, err := svc.GetServerRuntimeBindingForSession(ownerSession, instance.ID) + if err != nil || view.Configured || view.Reason != "runtime profile is not configured" { + t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err) + } + if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"}); err == nil || !strings.Contains(err.Error(), "runtime profile is not configured") { + t.Fatalf("expected missing binding to block start, got %v", err) + } + if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden { + t.Fatalf("expected non-owner update forbidden, got %v", err) + } + if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "unknown"}); err == nil { + t.Fatal("expected undeclared profile rejection") + } + if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "/srv/game"}}); err == nil { + t.Fatal("expected raw host path rejection") + } + + view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}) + if err != nil || view.Status != domain.RuntimeBindingStatusIncomplete || len(view.MissingKeys) != 1 || view.MissingKeys[0] != "rcon.password" { + t.Fatalf("unexpected incomplete binding: view=%+v err=%v", view, err) + } + if _, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-incomplete-binding"}); err == nil || !strings.Contains(err.Error(), "rcon.password") { + t.Fatalf("expected missing logical key to block start, got %v", err) + } + + view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon.password": "secret://runtime-server/rcon"}}) + if err != nil || view.Status != domain.RuntimeBindingStatusComplete || len(view.MissingKeys) != 0 { + t.Fatalf("unexpected complete binding: view=%+v err=%v", view, err) + } + result, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-complete-binding"}) + if err != nil || result.Job.TargetKey != "local" { + t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err) + } +} + +func requiredRuntimeProfilesFixture() domain.GamePluginRuntimeProfiles { + return domain.GamePluginRuntimeProfiles{ + Discovery: []domain.RuntimeDiscoveryProbe{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true}}, + LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}, TransportKeys: []string{"rcon"}}}, + TransportProfiles: []domain.RuntimeTransportProfile{{Key: "rcon", Kind: "rcon", TargetKey: "rcon.password", Capabilities: []string{"remote.run.rcon.command"}}}, + } +} diff --git a/platform/service/secret_envelope.go b/platform/service/secret_envelope.go new file mode 100644 index 0000000..eb88d2a --- /dev/null +++ b/platform/service/secret_envelope.go @@ -0,0 +1,98 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "strings" +) + +const developmentSecretEnvelopeKey = "browser.local/platform/development-secret-envelope/v1" + +type SecretEnvelope interface { + Seal(string) (string, error) + Open(string) (string, error) +} + +type aesGCMSecretEnvelope struct { + key [32]byte +} + +func newSecretEnvelope(secret string) *aesGCMSecretEnvelope { + return &aesGCMSecretEnvelope{key: sha256.Sum256([]byte(secret))} +} + +func (envelope *aesGCMSecretEnvelope) Seal(plain string) (string, error) { + block, err := aes.NewCipher(envelope.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + return "enc:v1:" + base64.RawURLEncoding.EncodeToString(nonce) + ":" + base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +func (envelope *aesGCMSecretEnvelope) Open(encrypted string) (string, error) { + parts := strings.Split(encrypted, ":") + if len(parts) != 4 || parts[0] != "enc" || parts[1] != "v1" { + return "", validationError("encrypted key format is invalid") + } + nonce, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", err + } + ciphertext, err := base64.RawURLEncoding.DecodeString(parts[3]) + if err != nil { + return "", err + } + block, err := aes.NewCipher(envelope.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (svc *CoreService) ConfigureSecretEnvelopeKey(secret string) error { + secret = strings.TrimSpace(secret) + if secret == "" { + return nil + } + if len([]rune(secret)) < 32 { + return validationError("PLATFORM_SECRET_ENVELOPE_KEY must be at least 32 characters") + } + svc.secretEnvelope = newSecretEnvelope(secret) + return nil +} + +func (svc *CoreService) encryptRuntimeKey(plain string) (string, error) { + return svc.secretEnvelope.Seal(plain) +} + +func (svc *CoreService) decryptRuntimeKey(encrypted string) (string, error) { + return svc.secretEnvelope.Open(encrypted) +} + +func encryptRuntimeKey(plain string) (string, error) { + return newSecretEnvelope(developmentSecretEnvelopeKey).Seal(plain) +} + +func decryptRuntimeKey(encrypted string) (string, error) { + return newSecretEnvelope(developmentSecretEnvelopeKey).Open(encrypted) +} diff --git a/platform/service/secret_envelope_test.go b/platform/service/secret_envelope_test.go new file mode 100644 index 0000000..14fe1a0 --- /dev/null +++ b/platform/service/secret_envelope_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "strings" + "testing" + + "browser.local/platform/repo" +) + +func TestConfiguredSecretEnvelopeIsOpaqueAndRestartStable(t *testing.T) { + const key = "test-secret-envelope-key-at-least-32-characters" + const plain = "raw-component-secret" + svc := NewCoreService(repo.NewMemoryStore()) + if err := svc.ConfigureSecretEnvelopeKey(key); err != nil { + t.Fatalf("configure envelope: %v", err) + } + encrypted, err := svc.encryptRuntimeKey(plain) + if err != nil { + t.Fatalf("seal secret: %v", err) + } + if strings.Contains(encrypted, plain) || !strings.HasPrefix(encrypted, "enc:v1:") { + t.Fatalf("unexpected envelope ciphertext %q", encrypted) + } + restarted := NewCoreService(repo.NewMemoryStore()) + if err := restarted.ConfigureSecretEnvelopeKey(key); err != nil { + t.Fatalf("configure restarted envelope: %v", err) + } + decrypted, err := restarted.decryptRuntimeKey(encrypted) + if err != nil || decrypted != plain { + t.Fatalf("open restarted envelope: plain=%q err=%v", decrypted, err) + } + if _, err := NewCoreService(repo.NewMemoryStore()).decryptRuntimeKey(encrypted); err == nil { + t.Fatal("development fallback must not decrypt a custom-key envelope") + } +} + +func TestSecretEnvelopeRejectsShortConfiguredKey(t *testing.T) { + svc := NewCoreService(repo.NewMemoryStore()) + if err := svc.ConfigureSecretEnvelopeKey("too-short"); err == nil { + t.Fatal("expected short secret envelope key rejection") + } +} diff --git a/platform/service/server_access.go b/platform/service/server_access.go index 3747ff0..73d4907b 100644 --- a/platform/service/server_access.go +++ b/platform/service/server_access.go @@ -26,7 +26,7 @@ func (svc *CoreService) requireServerOwner(sessionID string, serverInstanceID st if err != nil { return domain.User{}, domain.ServerInstance{}, err } - if instance.OwnerUserID != user.ID { + if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID { return domain.User{}, domain.ServerInstance{}, ErrForbidden } return user, instance, nil diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index 7e7e250..38d5edb 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -36,9 +36,13 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc OwnerUserID: create.OwnerUserID, State: domain.ServerInstanceStateInstalling, ConfigVersion: 1, + ConfigKey: "server.properties", CreatedAt: stamp, UpdatedAt: stamp, } + instance.ConfigContent = buildLogicalServerConfig(instance) + instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) + instance.ConfigUpdatedAt = stamp if err := validator.ValidateServerInstance(instance); err != nil { return domain.ServerLifecycleResult{}, err } @@ -51,9 +55,16 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil { return domain.ServerLifecycleResult{}, err } + binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: create.ProfileKey, Bindings: create.Bindings}, true) + if err != nil { + return domain.ServerLifecycleResult{}, err + } if err := svc.store.ServerInstances().Create(instance); err != nil { return domain.ServerLifecycleResult{}, err } + if err := svc.store.RuntimeBindings().Create(binding); err != nil { + return domain.ServerLifecycleResult{}, err + } job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey) if err != nil { @@ -108,6 +119,18 @@ func (svc *CoreService) StopServerInstanceForSession(sessionID string, command d return svc.StopServerInstance(command) } +func (svc *CoreService) QueryServerInstanceProcessForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return domain.ServerLifecycleResult{}, err + } + return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStatus, []domain.ServerInstanceState{ + domain.ServerInstanceStateReady, + domain.ServerInstanceStateStopped, + domain.ServerInstanceStateRunning, + domain.ServerInstanceStateFailed, + }) +} + func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLifecycleCommand, action domain.ServerLifecycleAction, allowedStates []domain.ServerInstanceState) (domain.ServerLifecycleResult, error) { command = domain.CopyServerLifecycleCommand(command) if err := validator.ValidateServerLifecycleCommand(command); err != nil { @@ -138,6 +161,9 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil { return domain.ServerLifecycleResult{}, err } + if err := svc.requireCompleteRuntimeBindings(instance.OwnerUserID, instance.ID, "server.lifecycle."+string(action)+".denied"); err != nil { + return domain.ServerLifecycleResult{}, err + } if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil { return domain.ServerLifecycleResult{}, err } @@ -168,12 +194,33 @@ func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID str func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) { capability := domain.LifecycleCapabilityForAction(action) + binding, err := svc.runtimeBindingForServer(instance.ID) + if err != nil { + return domain.Job{}, err + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return domain.Job{}, err + } + actionRef := binding.ProfileKey + if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); ok { + if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" { + actionRef = ref + } + } else if ref := lifecycleActionRef(plugin, action); ref != "" { + actionRef = ref + } + if strings.TrimSpace(actionRef) == "" { + return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action)) + } job, err := svc.CreateJob(domain.Job{ ID: lifecycleJobID(instance.ID, action, idempotencyKey), ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: capability, + TargetKey: actionRef, IdempotencyKey: idempotencyKey, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: binding.ProfileKey}, }) if err != nil { return domain.Job{}, err @@ -184,6 +231,30 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act return job, nil } +func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domain.ServerLifecycleAction) string { + switch action { + case domain.ServerLifecycleActionCreate: + return actions.Install + case domain.ServerLifecycleActionStart: + return actions.Start + case domain.ServerLifecycleActionStop: + return actions.Stop + case domain.ServerLifecycleActionStatus: + return actions.Status + default: + return "" + } +} + +func runtimeLifecycleProfileForKey(profiles domain.GamePluginRuntimeProfiles, key string) (domain.RuntimeLifecycleProfile, bool) { + for _, profile := range profiles.LifecycleProfiles { + if profile.Key == key { + return profile, true + } + } + return domain.RuntimeLifecycleProfile{}, false +} + func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error { existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey) if errors.Is(err, repo.ErrNotFound) { @@ -213,6 +284,8 @@ func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleA return plugin.LifecycleActions.Start case domain.ServerLifecycleActionStop: return plugin.LifecycleActions.Stop + case domain.ServerLifecycleActionStatus: + return plugin.LifecycleActions.Status default: return "" } diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index 03ad2fd..ba3f442 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -1,13 +1,55 @@ package service import ( + "strings" + "time" "browser.local/platform/domain" "browser.local/platform/validator" ) +func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error { + if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) { + return nil + } + result := domain.AuditResultSuccess + if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled { + result = domain.AuditResultFailed + } + summary := "remote adapter " + job.Capability + " completed with bounded result reference" + if job.State == domain.JobStateFailed { + summary = "remote adapter " + job.Capability + " failed or timed out; retry/fencing remained platform-owned" + } + if job.State == domain.JobStateCancelled { + summary = "remote adapter " + job.Capability + " was cancelled before terminal projection" + } + return svc.recordAuditEvent("run:"+job.RunEndpointID, "remote-adapter.result", "server-instance", job.ServerInstanceID, result, summary) +} + func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error { + if job.Capability == domain.JobCapabilityConfigWrite { + if job.State != domain.JobStateSucceeded { + return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", job.ServerInstanceID, domain.AuditResultFailed, job.ExecutionResult.AuditSummary) + } + instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) + if err != nil { + return err + } + instance.ConfigKey = job.TargetKey + instance.ConfigContent = job.ExecutionInput.Content + instance.ConfigChecksum = job.ExecutionResult.Checksum + instance.ConfigVersion = job.ExecutionResult.Version + instance.ConfigUpdatedAt = stamp + instance.UpdatedAt = stamp + if err := validator.ValidateServerInstance(instance); err != nil { + return err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return err + } + return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", instance.ID, domain.AuditResultSuccess, job.ExecutionResult.AuditSummary) + } nextState, ok := lifecycleProjectedState(job.Capability, job.State) if !ok || job.ServerInstanceID == "" { return nil @@ -21,7 +63,14 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim if err := validator.ValidateServerInstance(instance); err != nil { return err } - return svc.store.ServerInstances().Update(instance) + if err := svc.store.ServerInstances().Update(instance); err != nil { + return err + } + auditResult := domain.AuditResultSuccess + if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled { + auditResult = domain.AuditResultFailed + } + return svc.recordAuditEvent("run:"+job.RunEndpointID, "lifecycle.result", "server-instance", instance.ID, auditResult, job.Progress.Message) } func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) { diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index 855dd4a..2d4e6f7 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -17,6 +17,7 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) { RunEndpointID: "run-local", Name: "SCUM #1", IdempotencyKey: "idem-create", + ProfileKey: "local", }) if err != nil { t.Fatalf("create lifecycle workflow: %v", err) @@ -88,6 +89,7 @@ func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) { if err != nil { t.Fatalf("create ready server: %v", err) } + createCompleteRuntimeBinding(t, svc, instance, "local") _, err = svc.StartServerInstance(domain.ServerLifecycleCommand{ ServerInstanceID: instance.ID, @@ -121,6 +123,7 @@ func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) { State: domain.ServerInstanceStateRunning, }) if err == nil { + createCompleteRuntimeBinding(t, svc, running, "local") _, err = svc.StopServerInstance(domain.ServerLifecycleCommand{ ServerInstanceID: running.ID, ExpectedConfigVersion: running.ConfigVersion, @@ -141,6 +144,7 @@ func TestCoreServiceLifecycleFailureProjectsFailedState(t *testing.T) { RunEndpointID: "run-local", Name: "SCUM #1", IdempotencyKey: "idem-create", + ProfileKey: "local", }); err != nil { t.Fatalf("create lifecycle workflow: %v", err) } @@ -166,6 +170,7 @@ func TestCoreServicePluginLifecycleManagesMultipleInstancesIndependently(t *test RunEndpointID: "run-local", Name: id, IdempotencyKey: "idem-create-" + id, + ProfileKey: "local", }); err != nil { t.Fatalf("create %s: %v", id, err) } @@ -249,7 +254,8 @@ func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin { Start: "actions/start.json", Stop: "actions/stop.json", }, - Permissions: domain.PluginPermissions{Jobs: true, Logs: true}, + Permissions: domain.PluginPermissions{Jobs: true, Logs: true}, + RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}}, }) if err != nil { t.Fatalf("create lifecycle plugin: %v", err) diff --git a/platform/validator/artifact_transfer.go b/platform/validator/artifact_transfer.go index e5ab279..db38c63 100644 --- a/platform/validator/artifact_transfer.go +++ b/platform/validator/artifact_transfer.go @@ -9,7 +9,10 @@ import ( "browser.local/platform/domain" ) -const MaxArtifactChunkBytes = 1024 * 1024 +const ( + MaxArtifactChunkBytes = 1024 * 1024 + MaxArtifactBytes = int64(512 * 1024 * 1024) +) func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error { var violations []string @@ -31,6 +34,9 @@ func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error { if open.SizeBytes <= 0 { violations = append(violations, "sizeBytes must be positive") } + if open.SizeBytes > MaxArtifactBytes { + violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactBytes)) + } if open.ChunkSizeBytes <= 0 { violations = append(violations, "chunkSizeBytes must be positive") } diff --git a/platform/validator/auth.go b/platform/validator/auth.go new file mode 100644 index 0000000..3122d7c --- /dev/null +++ b/platform/validator/auth.go @@ -0,0 +1,52 @@ +package validator + +import ( + "strings" + + "browser.local/platform/domain" +) + +func ValidateAuthSessionRecord(session domain.AuthSessionRecord) error { + var violations []string + violations = appendRequired(violations, "id", session.ID) + violations = appendRequired(violations, "userId", session.UserID) + violations = appendRequired(violations, "tokenHash", session.TokenHash) + if len(strings.TrimSpace(session.TokenHash)) != 64 { + violations = append(violations, "tokenHash must be a SHA-256 verifier") + } + if session.Status != domain.AuthSessionStatusActive && session.Status != domain.AuthSessionStatusRevoked { + violations = append(violations, "status is invalid") + } + if session.Generation <= 0 { + violations = append(violations, "generation must be positive") + } + if session.IssuedAt.IsZero() || session.ExpiresAt.IsZero() || !session.ExpiresAt.After(session.IssuedAt) { + violations = append(violations, "session expiry must be after issue time") + } + if session.Status == domain.AuthSessionStatusRevoked && session.RevokedAt.IsZero() { + violations = append(violations, "revokedAt is required for revoked sessions") + } + return finish(violations) +} + +func ValidateRunControlSession(session domain.RunControlSession) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", session.RunEndpointID) + violations = appendRequired(violations, "sessionTokenHash", session.SessionTokenHash) + if len(strings.TrimSpace(session.SessionTokenHash)) != 64 { + violations = append(violations, "sessionTokenHash must be a SHA-256 verifier") + } + if session.Status != domain.AuthSessionStatusActive && session.Status != domain.AuthSessionStatusRevoked { + violations = append(violations, "status is invalid") + } + if session.Generation <= 0 { + violations = append(violations, "generation must be positive") + } + if session.CreatedAt.IsZero() || session.ExpiresAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) { + violations = append(violations, "session expiry must be after creation time") + } + if session.Status == domain.AuthSessionStatusRevoked && session.RevokedAt.IsZero() { + violations = append(violations, "revokedAt is required for revoked sessions") + } + return finish(violations) +} diff --git a/platform/validator/client_manager_lifecycle.go b/platform/validator/client_manager_lifecycle.go new file mode 100644 index 0000000..9cdd6dd --- /dev/null +++ b/platform/validator/client_manager_lifecycle.go @@ -0,0 +1,231 @@ +package validator + +import ( + "regexp" + "strings" + + "browser.local/platform/domain" +) + +var clientManagerIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$`) + +func ValidateClientManagerInstallation(value domain.ClientManagerInstallation) error { + var violations []string + violations = appendRequired(violations, "id", value.ID) + violations = appendRequired(violations, "serverInstanceId", value.ServerInstanceID) + violations = appendRequired(violations, "pluginId", value.PluginID) + violations = appendRequired(violations, "profileKey", value.ProfileKey) + violations = appendRequired(violations, "runEndpointId", value.RunEndpointID) + if !validDistributionLogicalKey(value.ProfileKey) { + violations = append(violations, "profileKey is invalid") + } + if value.TargetOS != "" || value.TargetArch != "" { + violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch) + } + if !validClientManagerLifecycleStatus(value.Status) { + violations = append(violations, "status is invalid") + } + if !validClientManagerHealth(value.Health) { + violations = append(violations, "health is invalid") + } + if value.KeyGeneration < 0 || value.DeploymentGeneration < 0 { + violations = append(violations, "keyGeneration and deploymentGeneration must not be negative") + } + if value.Checksum != "" && !validSHA256Checksum(value.Checksum) { + violations = append(violations, "checksum must be sha256:") + } + for field, content := range map[string]string{"phase": value.Phase, "healthReason": value.HealthReason} { + if len(content) > 240 || unsafeLifecycleText(content) { + violations = append(violations, field+" must be bounded and redacted") + } + } + if value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() { + violations = append(violations, "createdAt and updatedAt are required") + } + return finish(violations) +} + +func ValidateClientManagerLifecycleTransition(from, to domain.ClientManagerLifecycleStatus) error { + if from == to { + return nil + } + allowed := map[domain.ClientManagerLifecycleStatus][]domain.ClientManagerLifecycleStatus{ + domain.ClientManagerLifecycleRequested: {domain.ClientManagerLifecycleBuilding, domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleBuilding: {domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleAvailable: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleDeploying: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleFailed, domain.ClientManagerLifecycleUninstalled}, + domain.ClientManagerLifecycleInstalled: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleRegistering: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleOnline: {domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleDegraded: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleOffline: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleUpdating: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleRollingBack: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleStopping: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleUninstalled: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed}, + domain.ClientManagerLifecycleFailed: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled}, + } + for _, candidate := range allowed[from] { + if candidate == to { + return nil + } + } + return finish([]string{"client-manager lifecycle transition is invalid"}) +} + +func ValidateClientManagerSession(value domain.ClientManagerSession) error { + var violations []string + for field, content := range map[string]string{"id": value.ID, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "runEndpointId": value.RunEndpointID, "artifactId": value.ArtifactID, "tokenHash": value.TokenHash} { + violations = appendRequired(violations, field, content) + } + if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 { + violations = append(violations, "keyGeneration and deploymentGeneration must be positive") + } + if len(value.TokenHash) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.TokenHash) { + violations = append(violations, "tokenHash must be a SHA-256 digest") + } + if !oneOf(string(value.Status), string(domain.ClientManagerSessionActive), string(domain.ClientManagerSessionRevoked), string(domain.ClientManagerSessionExpired)) { + violations = append(violations, "status is invalid") + } + if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() { + violations = append(violations, "session timestamps are required") + } + return finish(violations) +} + +func ValidateClientManagerNonce(value domain.ClientManagerRegistrationNonce) error { + var violations []string + violations = appendRequired(violations, "id", value.ID) + violations = appendRequired(violations, "installationId", value.InstallationID) + if len(value.ID) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.ID) { + violations = append(violations, "id must be a nonce SHA-256 digest") + } + if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || !value.ExpiresAt.After(value.CreatedAt) { + violations = append(violations, "nonce expiry must follow creation") + } + return finish(violations) +} + +func ValidateClientManagerDeployRequest(value domain.ClientManagerDeployRequest) error { + return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true) +} + +func ValidateClientManagerControlRequest(value domain.ClientManagerControlRequest) error { + if !oneOf(string(value.Operation), "start", "stop", "restart", "status", "rollback") { + return finish([]string{"operation is invalid"}) + } + return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false) +} + +func ValidateClientManagerUpdateRequest(value domain.ClientManagerUpdateRequest) error { + var violations []string + if !value.Approved { + violations = append(violations, "approved must be true") + } + if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true); err != nil { + violations = append(violations, err.Error()) + } + return finish(violations) +} + +func ValidateClientManagerUninstallRequest(value domain.ClientManagerUninstallRequest) error { + var violations []string + if !value.Confirmed { + violations = append(violations, "confirmed must be true") + } + if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false); err != nil { + violations = append(violations, err.Error()) + } + return finish(violations) +} + +func ValidateClientManagerRetryRequest(value domain.ClientManagerRetryRequest) error { + return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false) +} + +func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecycleInputRequest) error { + var violations []string + for field, content := range map[string]string{"runEndpointId": value.RunEndpointID, "sessionToken": value.SessionToken, "jobId": value.JobID, "leaseToken": value.LeaseToken} { + violations = appendRequired(violations, field, content) + } + if value.Attempt <= 0 { + violations = append(violations, "attempt must be positive") + } + return finish(violations) +} + +func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error { + var violations []string + for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} { + violations = appendRequired(violations, field, content) + } + violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch) + if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 { + violations = append(violations, "keyGeneration and deploymentGeneration must be positive") + } + if value.Timestamp.IsZero() { + violations = append(violations, "timestamp is required") + } + if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`).MatchString(value.Nonce) { + violations = append(violations, "nonce is invalid") + } + if !regexp.MustCompile(`^sha256:[a-f0-9]{64}$`).MatchString(value.Signature) { + violations = append(violations, "signature is invalid") + } + if len(value.Capabilities) == 0 { + violations = append(violations, "capabilities must not be empty") + } + return finish(violations) +} + +func ValidateClientManagerHeartbeat(value domain.ClientManagerHeartbeat) error { + var violations []string + violations = appendRequired(violations, "installationId", value.InstallationID) + violations = appendRequired(violations, "sessionToken", value.SessionToken) + if value.Sequence == 0 { + violations = append(violations, "sequence must be positive") + } + if !validClientManagerHealth(value.Health) || value.Health == domain.ClientManagerHealthUnknown { + violations = append(violations, "health is invalid") + } + if len(value.HealthReason) > 240 || unsafeLifecycleText(value.HealthReason) { + violations = append(violations, "healthReason must be bounded and redacted") + } + if value.SentAt.IsZero() { + violations = append(violations, "sentAt is required") + } + return finish(violations) +} + +func validateClientManagerOperationRequest(serverID, profileKey, distributionID, idempotencyKey string, generation int, distributionRequired bool) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", serverID) + violations = appendRequired(violations, "profileKey", profileKey) + violations = appendRequired(violations, "idempotencyKey", idempotencyKey) + if distributionRequired { + violations = appendRequired(violations, "distributionId", distributionID) + } + if !validDistributionLogicalKey(profileKey) { + violations = append(violations, "profileKey is invalid") + } + if !clientManagerIdentifierPattern.MatchString(idempotencyKey) { + violations = append(violations, "idempotencyKey is invalid") + } + if generation < 0 { + violations = append(violations, "expectedDeploymentGeneration must not be negative") + } + return finish(violations) +} + +func validClientManagerLifecycleStatus(value domain.ClientManagerLifecycleStatus) bool { + return oneOf(string(value), "requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed") +} + +func validClientManagerHealth(value domain.ClientManagerHealthStatus) bool { + return oneOf(string(value), "unknown", "healthy", "degraded", "unhealthy", "offline") +} + +func unsafeLifecycleText(value string) bool { + lowered := strings.ToLower(strings.TrimSpace(value)) + return strings.Contains(lowered, "secret://") || strings.Contains(lowered, "bearer ") || strings.Contains(lowered, "password=") || strings.Contains(lowered, "token=") || strings.Contains(lowered, "unix://") || strings.Contains(lowered, "tcp://") || looksLikeRawHostPath(value) +} diff --git a/platform/validator/client_manager_lifecycle_test.go b/platform/validator/client_manager_lifecycle_test.go new file mode 100644 index 0000000..a8bf3b3 --- /dev/null +++ b/platform/validator/client_manager_lifecycle_test.go @@ -0,0 +1,50 @@ +package validator + +import ( + "strings" + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestValidateClientManagerLifecycleContractsAndTransitions(t *testing.T) { + profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.2.3", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"client-manager"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "client-manager", Arguments: []string{"--config", "config.json"}, RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0", MaximumVersion: "2.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}} + if err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}}); err != nil { + t.Fatalf("validate safe lifecycle profile: %v", err) + } + unsafe := profile + unsafe.Deployment.ExecutableRef = "/Users/operator/client-manager" + unsafe.Deployment.Arguments = []string{"bash -c", "curl | bash"} + unsafe.Health.OfflineAfterSeconds = 30 + unsafe.Compatibility.MinimumVersion = "3.0.0" + err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{unsafe}}) + if err == nil || !strings.Contains(err.Error(), "safe relative path") || !strings.Contains(err.Error(), "health") || !strings.Contains(err.Error(), "compatibility") { + t.Fatalf("expected unsafe lifecycle rejection, got %v", err) + } + if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying); err != nil { + t.Fatalf("valid lifecycle transition rejected: %v", err) + } + if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleOnline); err == nil { + t.Fatal("expected evidence-skipping lifecycle transition rejection") + } +} + +func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) { + stamp := time.Date(2026, 7, 18, 4, 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", KeyGeneration: 1, DeploymentGeneration: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", CreatedAt: stamp, UpdatedAt: stamp} + if err := ValidateClientManagerInstallation(installation); err != nil { + t.Fatalf("validate installation: %v", err) + } + installation.HealthReason = "Bearer stolen-session" + if err := ValidateClientManagerInstallation(installation); err == nil { + t.Fatal("expected session-bearing health reason rejection") + } + session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: "cm-install-1", ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", ArtifactID: "artifact-1", KeyGeneration: 1, DeploymentGeneration: 1, TokenHash: strings.Repeat("a", 64), Capabilities: []string{"component.heartbeat"}, Status: domain.ClientManagerSessionActive, ExpiresAt: stamp.Add(time.Minute), CreatedAt: stamp, UpdatedAt: stamp} + if err := ValidateClientManagerSession(session); err != nil { + t.Fatalf("validate hashed session: %v", err) + } + if err := ValidateClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: "cm-install-1", SessionToken: "component-session", Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "password=leak", Capabilities: []string{"component.heartbeat"}, SentAt: stamp}); err == nil { + t.Fatal("expected unsafe heartbeat reason rejection") + } +} diff --git a/platform/validator/control.go b/platform/validator/control.go index f14de0b..4133c3d 100644 --- a/platform/validator/control.go +++ b/platform/validator/control.go @@ -13,6 +13,20 @@ func ValidateRunControlHello(hello domain.RunControlHello) error { violations = appendRequired(violations, "runEndpointId", hello.RunEndpointID) violations = appendRequired(violations, "displayName", hello.DisplayName) violations = appendRequired(violations, "version", hello.Version) + if hello.Architecture != "" { + if !validDistributionTargetOS(hello.Platform) { + violations = append(violations, "platform is invalid") + } + if !validDistributionTargetArch(hello.Architecture) { + violations = append(violations, "architecture is invalid") + } + } + if (hello.UpdateJobID == "") != (hello.UpdateOutcome == "") { + violations = append(violations, "updateJobId and updateOutcome must be provided together") + } + if hello.UpdateOutcome != "" && hello.UpdateOutcome != "succeeded" && hello.UpdateOutcome != "rolled-back" { + violations = append(violations, "updateOutcome is invalid") + } violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint) if hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0 { violations = appendRequired(violations, "serverInstanceId", hello.ServerInstanceID) diff --git a/platform/validator/distributions.go b/platform/validator/distributions.go index cc02c17..9eb55ed 100644 --- a/platform/validator/distributions.go +++ b/platform/validator/distributions.go @@ -2,6 +2,7 @@ package validator import ( "fmt" + "net/url" "strings" "browser.local/platform/domain" @@ -14,6 +15,7 @@ func ValidateRuntimeBinding(binding domain.RuntimeBinding) error { violations = appendRequired(violations, "id", binding.ID) violations = appendRequired(violations, "serverInstanceId", binding.ServerInstanceID) violations = appendRequired(violations, "pluginId", binding.PluginID) + violations = appendRequired(violations, "pluginVersion", binding.PluginVersion) violations = appendRequired(violations, "profileKey", binding.ProfileKey) violations = appendRequired(violations, "mode", binding.Mode) if !validRuntimeBindingStatus(binding.Status) { @@ -23,7 +25,10 @@ func ValidateRuntimeBinding(binding domain.RuntimeBinding) error { if !validDistributionLogicalKey(key) { violations = append(violations, "bindings key is invalid") } - if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "://") && !strings.HasPrefix(value, "secret://") { + trimmed := strings.TrimSpace(value) + lowerKey := strings.ToLower(key) + sensitiveKey := strings.Contains(lowerKey, "password") || strings.Contains(lowerKey, "credential") || strings.Contains(lowerKey, "secret") || strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "dsn") + if trimmed != value || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "://") && !strings.HasPrefix(value, "secret://") || sensitiveKey && value != "" && !strings.HasPrefix(value, "secret://") { violations = append(violations, "bindings."+key+" must use safe logical or secret refs") } } @@ -176,6 +181,18 @@ func ValidateDependencyStatus(status domain.DependencyStatus) error { if status.InstallPlanKey != "" && !validDistributionLogicalKey(status.InstallPlanKey) { violations = append(violations, "installPlanKey is invalid") } + if status.PlanDigest != "" && !validSHA256Checksum(status.PlanDigest) { + violations = append(violations, "planDigest must be sha256:") + } + if len(status.JobID) > 180 || containsUnsafeRuntimeSecret(status.JobID) || looksLikeRawHostPath(status.JobID) { + violations = append(violations, "jobId is unsafe or too long") + } + if status.CompletedSteps < 0 || status.CompletedSteps > 64 { + violations = append(violations, "completedSteps is out of bounds") + } + if len(status.Evidence) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(status.Evidence) || looksLikeRawHostPath(status.Evidence) { + violations = append(violations, "evidence is unsafe or too long") + } if len(status.Message) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(status.Message) || looksLikeRawHostPath(status.Message) { violations = append(violations, "message is unsafe or too long") } @@ -229,6 +246,9 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error { violations = appendRequired(violations, "runEndpointId", job.RunEndpointID) violations = appendRequired(violations, "artifactId", job.ArtifactID) violations = appendRequired(violations, "checksum", job.Checksum) + violations = appendRequired(violations, "targetOs", job.TargetOS) + violations = appendRequired(violations, "targetArch", job.TargetArch) + violations = appendRequired(violations, "targetRelease", job.TargetRelease) violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey) if job.Checksum != "" && !validSHA256Checksum(job.Checksum) { violations = append(violations, "checksum must be sha256:") @@ -236,6 +256,13 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error { if !validDistributionJobStatus(job.Status) { violations = append(violations, "status is invalid") } + violations = appendDistributionTargetViolations(violations, job.TargetOS, job.TargetArch) + if !validRunUpdatePhase(job.Phase) { + violations = append(violations, "phase is invalid") + } + if len(job.Message) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(job.Message) || looksLikeRawHostPath(job.Message) { + violations = append(violations, "message is unsafe or too long") + } if containsUnsafeRuntimeSecret(job.IdempotencyKey) || looksLikeRawHostPath(job.IdempotencyKey) { violations = append(violations, "idempotencyKey is unsafe") } @@ -248,6 +275,15 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error { return finish(violations) } +func validRunUpdatePhase(phase domain.RunUpdatePhase) bool { + switch phase { + case domain.RunUpdatePhaseQueued, domain.RunUpdatePhaseDownloading, domain.RunUpdatePhaseStaged, domain.RunUpdatePhaseRestartRequested, domain.RunUpdatePhaseActivating, domain.RunUpdatePhaseSucceeded, domain.RunUpdatePhaseRolledBack, domain.RunUpdatePhaseFailed: + return true + default: + return false + } +} + func ValidateRunDistributionGenerateRequest(request domain.RunDistributionGenerateRequest) error { var violations []string violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) @@ -341,8 +377,8 @@ func validateRepositoryURL(field string, value string) []string { if strings.TrimSpace(value) == "" { return nil } - lowered := strings.ToLower(strings.TrimSpace(value)) - if !strings.HasPrefix(lowered, "https://") || !strings.HasSuffix(lowered, ".git") { + parsed, err := url.ParseRequestURI(strings.TrimSpace(value)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") { return []string{field + " must be an HTTPS git repository URL"} } for _, reason := range unsafePluginStringReasons(value) { diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index cc41aef..0e640a1 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -45,6 +45,12 @@ func ValidateRunJobResult(result domain.RunJobResult) error { violations = appendProgressViolations(violations, result.Progress) violations = appendMessageLength(violations, "message", result.Message) violations = appendMessageLength(violations, "errorCode", result.ErrorCode) + if len([]byte(result.ExecutionResult.Content)) > maxJobChannelMessageLength*256 { + violations = append(violations, "executionResult.content is too large") + } + if result.ExecutionResult.Checksum != "" && !validSHA256Checksum(result.ExecutionResult.Checksum) { + violations = append(violations, "executionResult.checksum must be sha256:") + } return finish(violations) } @@ -54,6 +60,35 @@ func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInput return finish(violations) } +func ValidateDependencyExecutionInputRequest(request domain.DependencyExecutionInputRequest) error { + return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)) +} + +func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error { + return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)) +} + +func ValidateRunUpdateChunkRequest(request domain.RunUpdateChunkRequest) error { + violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) + if request.Offset < 0 { + violations = append(violations, "offset must not be negative") + } + if request.Length <= 0 || request.Length > 1024*1024 { + violations = append(violations, "length must be between 1 and 1048576") + } + return finish(violations) +} + +func ValidateRunUpdateHealthReport(report domain.RunUpdateHealthReport) error { + violations := appendLeaseFields(nil, report.RunEndpointID, report.SessionToken, report.JobID, report.LeaseToken, report.Attempt) + if report.Outcome != "succeeded" && report.Outcome != "rolled-back" { + violations = append(violations, "outcome must be succeeded or rolled-back") + } + violations = appendRequired(violations, "version", report.Version) + violations = appendMessageLength(violations, "version", report.Version) + return finish(violations) +} + func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error { var violations []string violations = appendRequired(violations, "jobId", request.JobID) @@ -64,11 +99,7 @@ func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error { func ValidateRunJobCancelPoll(poll domain.RunJobCancelPoll) error { var violations []string - violations = appendRequired(violations, "runEndpointId", poll.RunEndpointID) - violations = appendRequired(violations, "sessionToken", poll.SessionToken) - if poll.LeaseToken != "" && strings.TrimSpace(poll.JobID) == "" { - violations = append(violations, "jobId is required when leaseToken is provided") - } + violations = appendLeaseFields(violations, poll.RunEndpointID, poll.SessionToken, poll.JobID, poll.LeaseToken, poll.Attempt) return finish(violations) } @@ -77,16 +108,17 @@ func ValidateRunJobReconcile(reconcile domain.RunJobReconcile) error { violations = appendRequired(violations, "runEndpointId", reconcile.RunEndpointID) violations = appendRequired(violations, "sessionToken", reconcile.SessionToken) seen := map[string]struct{}{} - for i, jobID := range reconcile.ActiveJobIDs { - jobID = strings.TrimSpace(jobID) - if jobID == "" { - violations = append(violations, fmt.Sprintf("activeJobIds[%d] is required", i)) - continue + for i, entry := range reconcile.ActiveJobs { + prefix := fmt.Sprintf("activeJobs[%d]", i) + violations = appendRequired(violations, prefix+".jobId", entry.JobID) + violations = appendRequired(violations, prefix+".leaseToken", entry.LeaseToken) + if entry.Attempt <= 0 { + violations = append(violations, prefix+".attempt must be positive") } - if _, exists := seen[jobID]; exists { - violations = append(violations, fmt.Sprintf("activeJobIds[%d] duplicates %q", i, jobID)) + if _, exists := seen[entry.JobID]; exists { + violations = append(violations, fmt.Sprintf("%s duplicates %q", prefix, entry.JobID)) } - seen[jobID] = struct{}{} + seen[entry.JobID] = struct{}{} } return finish(violations) } diff --git a/platform/validator/observability.go b/platform/validator/observability.go new file mode 100644 index 0000000..e2c210d --- /dev/null +++ b/platform/validator/observability.go @@ -0,0 +1,100 @@ +package validator + +import ( + "fmt" + "strings" + + "browser.local/platform/domain" +) + +const ( + MaxMetricSamplesPerQuery = 500 + MaxBackupRecordsPerQuery = 200 +) + +func ValidateMetricSample(sample domain.MetricSample) error { + var violations []string + violations = appendRequired(violations, "id", sample.ID) + violations = appendRequired(violations, "serverInstanceId", sample.ServerInstanceID) + violations = appendRequired(violations, "source", sample.Source) + if sample.CollectedAt.IsZero() { + violations = append(violations, "collectedAt is required") + } + for name, value := range map[string]*float64{"tps": sample.TPS, "latencyMs": sample.LatencyMS, "cpuPercent": sample.CPUPercent, "memoryPercent": sample.MemoryPercent, "diskPercent": sample.DiskPercent} { + if value != nil && (value == nil || *value < 0) { + violations = append(violations, fmt.Sprintf("%s must not be negative", name)) + } + } + return finish(violations) +} + +func ValidateMetricSampleFilter(filter domain.MetricSampleFilter) error { + var violations []string + if filter.Limit < 0 || filter.Limit > MaxMetricSamplesPerQuery { + violations = append(violations, fmt.Sprintf("limit must be between 0 and %d", MaxMetricSamplesPerQuery)) + } + if filter.Before.Before(filter.After) { + violations = append(violations, "before must not precede after") + } + return finish(violations) +} + +func ValidateBackupRecord(record domain.BackupRecord) error { + var violations []string + violations = appendRequired(violations, "id", record.ID) + violations = appendRequired(violations, "serverInstanceId", record.ServerInstanceID) + violations = appendRequired(violations, "artifactId", record.ArtifactID) + violations = appendRequired(violations, "checksum", record.Checksum) + if record.SizeBytes <= 0 { + violations = append(violations, "sizeBytes must be positive") + } + if record.State != domain.BackupStatePending && record.State != domain.BackupStateAvailable && record.State != domain.BackupStateFailed && record.State != domain.BackupStateExpired { + violations = append(violations, "state is invalid") + } + if len(record.RecoveryStatus) > 256 { + violations = append(violations, "recoveryStatus is too long") + } + return finish(violations) +} + +func ValidateBackupFilter(filter domain.BackupFilter) error { + if filter.State != "" && filter.State != domain.BackupStatePending && filter.State != domain.BackupStateAvailable && filter.State != domain.BackupStateFailed && filter.State != domain.BackupStateExpired { + return ValidationError{Violations: []string{"state is invalid"}} + } + return nil +} + +func ValidateRemoteAdapterDeclaration(declaration domain.RemoteAdapterDeclaration) error { + var violations []string + violations = appendRequired(violations, "key", declaration.Key) + violations = appendRequired(violations, "kind", string(declaration.Kind)) + if len(declaration.TargetKeys) == 0 { + violations = append(violations, "targetKeys must not be empty") + } + if declaration.TimeoutSeconds <= 0 || declaration.TimeoutSeconds > 300 { + violations = append(violations, "timeoutSeconds must be between 1 and 300") + } + if declaration.MaxAttempts <= 0 || declaration.MaxAttempts > 5 { + violations = append(violations, "maxAttempts must be between 1 and 5") + } + return finish(violations) +} + +func ValidateRemoteAdapterRequest(request domain.RemoteAdapterRequest) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "declarationKey", request.DeclarationKey) + violations = appendRequired(violations, "targetKey", request.TargetKey) + violations = appendRequired(violations, "capability", request.Capability) + violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) + if request.TimeoutSeconds < 0 || request.TimeoutSeconds > 300 { + violations = append(violations, "timeoutSeconds must be between 0 and 300") + } + if request.MaxAttempts < 0 || request.MaxAttempts > 5 { + violations = append(violations, "maxAttempts must be between 0 and 5") + } + if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") { + violations = append(violations, "targetKey must be a logical key") + } + return finish(violations) +} diff --git a/platform/validator/observability_test.go b/platform/validator/observability_test.go new file mode 100644 index 0000000..5477655 --- /dev/null +++ b/platform/validator/observability_test.go @@ -0,0 +1,23 @@ +package validator + +import ( + "strings" + "testing" + "time" + + "browser.local/platform/domain" +) + +func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T) { + if err := ValidateMetricSample(domain.MetricSample{ID: "metric-1", ServerInstanceID: "server-1", Source: "run", CollectedAt: time.Now(), CPUPercent: floatPtr(-1)}); err == nil { + t.Fatal("expected negative metric rejection") + } + if err := ValidateBackupRecord(domain.BackupRecord{ID: "backup-1", ServerInstanceID: "server-1", ArtifactID: "artifact-1", SizeBytes: 1, Checksum: "sha256:" + strings.Repeat("0", 64), State: domain.BackupStateAvailable, RecoveryStatus: strings.Repeat("x", 257)}); err == nil { + t.Fatal("expected oversized recovery status rejection") + } + if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil { + t.Fatal("expected unsafe remote target rejection") + } +} + +func floatPtr(value float64) *float64 { return &value } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 3830ba8..69a7a18 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -18,6 +18,7 @@ const ( maxPluginBridgePayloadSize = 4096 maxProgressMessageLength = 256 maxServerConfigContentSize = 64 * 1024 + maxJobExecutionContentSize = 64 * 1024 maxLogicalFileKeyLength = 160 ) @@ -146,6 +147,10 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { violations = append(violations, duplicateViolations("tags", plugin.Tags)...) violations = append(violations, validateAIPurposes(plugin.AIPurposes)...) violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...) + if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil { + violations = append(violations, err.Error()) + } + violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...) return finish(violations) } @@ -198,6 +203,10 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...) violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...) violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...) + if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil { + violations = append(violations, err.Error()) + } + violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...) violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...) return finish(violations) } @@ -549,6 +558,9 @@ func ValidateServerConfig(config domain.ServerConfig) error { if len([]byte(config.Content)) > maxServerConfigContentSize { violations = append(violations, "content is too large") } + if config.Checksum != "" && !validSHA256Checksum(config.Checksum) { + violations = append(violations, "checksum must be sha256:") + } if config.UpdatedAt.IsZero() { violations = append(violations, "updatedAt is required") } @@ -620,6 +632,12 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe if request.InputRef != "" && !validScopedInputRef(request.InputRef) { violations = append(violations, "inputRef is not allowed") } + if len([]byte(request.Content)) > maxJobExecutionContentSize { + violations = append(violations, "content is too large") + } + if containsUnsafeRuntimeSecret(request.Content) { + violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets") + } if request.ExpectedConfigVersion < 0 { violations = append(violations, "expectedConfigVersion must not be negative") } @@ -672,6 +690,14 @@ func ValidateRunEndpoint(endpoint domain.RunEndpoint) error { violations = appendRequired(violations, "id", endpoint.ID) violations = appendRequired(violations, "displayName", endpoint.DisplayName) violations = appendRequired(violations, "version", endpoint.Version) + if endpoint.Architecture != "" { + if !validDistributionTargetOS(endpoint.Platform) { + violations = append(violations, "platform is invalid") + } + if !validDistributionTargetArch(endpoint.Architecture) { + violations = append(violations, "architecture is invalid") + } + } if !validRunEndpointStatus(endpoint.Status) { violations = append(violations, "status is invalid") } @@ -704,12 +730,51 @@ func ValidateJob(job domain.Job) error { if len(job.Progress.Message) > maxProgressMessageLength { violations = append(violations, "progress.message is too long") } + if job.Attempt < 0 { + violations = append(violations, "attempt must not be negative") + } + if job.RetryPolicy.MaxAttempts <= 0 { + violations = append(violations, "retryPolicy.maxAttempts must be positive") + } + if job.RetryPolicy.InitialBackoffSeconds <= 0 || job.RetryPolicy.MaxBackoffSeconds < job.RetryPolicy.InitialBackoffSeconds { + violations = append(violations, "retryPolicy backoff must be positive and bounded") + } + if job.Attempt > job.RetryPolicy.MaxAttempts { + violations = append(violations, "attempt must not exceed retryPolicy.maxAttempts") + } + if job.LeaseTokenHash != "" && len(job.LeaseTokenHash) != 64 { + violations = append(violations, "leaseTokenHash must be a SHA-256 hash") + } + if len(job.CancelReason) > maxProgressMessageLength { + violations = append(violations, "cancelReason is too long") + } + if len(job.ReconcileOutcome) > maxProgressMessageLength { + violations = append(violations, "reconcileOutcome is too long") + } if job.TargetKey != "" && !validLogicalFileKey(job.TargetKey) { violations = append(violations, "targetKey is not allowed") } if job.InputRef != "" && !validScopedInputRef(job.InputRef) { violations = append(violations, "inputRef is not allowed") } + if len([]byte(job.ExecutionInput.Content)) > maxJobExecutionContentSize { + violations = append(violations, "executionInput.content is too large") + } + if job.ExecutionInput.MaxReadBytes < 0 || job.ExecutionInput.MaxReadBytes > maxJobExecutionContentSize { + violations = append(violations, "executionInput.maxReadBytes is out of bounds") + } + if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) { + violations = append(violations, "executionInput.expectedChecksum must be sha256:") + } + if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) { + violations = append(violations, "executionResult.checksum must be sha256:") + } + if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize { + violations = append(violations, "executionResult.content is too large") + } + if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength { + violations = append(violations, "executionResult.auditSummary is too long") + } if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.ServerInstanceID == "" { violations = append(violations, "serverInstanceId is required for scoped file jobs") @@ -1242,6 +1307,8 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, + domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, + domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, "artifacts.read", "artifacts.write", "artifact.read", "artifact.write", "ai.invoke": return true @@ -1497,7 +1564,7 @@ func validRunEndpointStatus(status domain.RunEndpointStatus) bool { func validJobState(state domain.JobState) bool { switch state { - case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled: + case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled: return true default: return false diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 5f6c65a..93ba439 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -52,6 +52,26 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing. } } +func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) { + t.Run("unsafe runtime value", func(t *testing.T) { + registration := validGamePluginManifestRegistration() + registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{Discovery: []domain.RuntimeDiscoveryProbe{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true, Expected: "/Users/operator/server"}}} + err := ValidateGamePluginManifestRegistration(registration) + if err == nil || !strings.Contains(err.Error(), "unsafe runtime content") { + t.Fatalf("expected unsafe runtime profile rejection, got %v", err) + } + }) + + t.Run("undeclared transport reference", func(t *testing.T) { + registration := validGamePluginManifestRegistration() + registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}, TransportKeys: []string{"missing-transport"}}}} + err := ValidateGamePluginManifestRegistration(registration) + if err == nil || !strings.Contains(err.Error(), "undeclared transport") { + t.Fatalf("expected cross-profile reference rejection, got %v", err) + } + }) +} + func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) { registration := validGamePluginManifestRegistration() registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket") diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go new file mode 100644 index 0000000..2aa4fd4 --- /dev/null +++ b/platform/validator/runtime_profiles.go @@ -0,0 +1,440 @@ +package validator + +import ( + "encoding/hex" + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "browser.local/platform/domain" +) + +func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error { + profiles = domain.CopyGamePluginRuntimeProfiles(profiles) + var violations []string + lifecycleKeys := map[string]struct{}{} + transportKeys := map[string]struct{}{} + managerKeys := map[string]struct{}{} + discoveryKeys := map[string]struct{}{} + dependencyKeys := map[string]struct{}{} + installPlanKeys := map[string]struct{}{} + logSourceKeys := map[string]struct{}{} + + for i, probe := range profiles.Discovery { + prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...) + violations = append(violations, recordRuntimeProfileKey(discoveryKeys, prefix+".key", probe.Key)...) + violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...) + if !oneOf(probe.Kind, "file.exists", "command.version", "service.status", "port.open", "steam.app", "docker.container") { + violations = append(violations, prefix+".kind is invalid") + } + violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...) + violations = append(violations, validateSafeRuntimeValue(prefix+".expected", probe.Expected)...) + } + for i, profile := range profiles.LifecycleProfiles { + prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...) + violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...) + if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only", "custom-client") { + violations = append(violations, prefix+".mode is invalid") + } + if len(profile.Capabilities) == 0 { + violations = append(violations, prefix+".capabilities must not be empty") + } + for j, capability := range profile.Capabilities { + if !validPluginRunCapability(capability) { + violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j)) + } + } + violations = append(violations, duplicateViolations(prefix+".capabilities", profile.Capabilities)...) + violations = append(violations, validateLifecycleActionsOptional(profile.ActionRefs)...) + for j, key := range profile.TransportKeys { + violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...) + } + violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...) + if profile.ClientManagerRef != "" { + violations = append(violations, validateProfileKey(prefix+".clientManagerRef", profile.ClientManagerRef)...) + } + violations = append(violations, validateRuntimePlatforms(prefix+".platforms", profile.Platforms)...) + } + for i, probe := range profiles.DependencyProbes { + prefix := fmt.Sprintf("runtimeProfiles.dependencyProbes[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...) + violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...) + violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...) + if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists") { + violations = append(violations, prefix+".kind is invalid") + } + violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...) + violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...) + } + for i, plan := range profiles.InstallPlans { + prefix := fmt.Sprintf("runtimeProfiles.installPlans[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", plan.Key)...) + violations = append(violations, recordRuntimeProfileKey(installPlanKeys, prefix+".key", plan.Key)...) + violations = appendRequired(violations, prefix+".title", plan.Title) + violations = append(violations, validateSafeRuntimeValue(prefix+".title", plan.Title)...) + if len(plan.Steps) == 0 { + violations = append(violations, prefix+".steps must not be empty") + } + if len(plan.Steps) > 64 { + violations = append(violations, prefix+".steps must not exceed 64") + } + for j, step := range plan.Steps { + stepPrefix := fmt.Sprintf("%s.steps[%d]", prefix, j) + if !oneOf(step.Type, "package", "verified-download", "steamcmd-app", "manual") { + violations = append(violations, stepPrefix+".type is invalid") + } + violations = append(violations, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...) + for field, value := range map[string]string{"packageManager": step.PackageManager, "packageName": step.PackageName, "version": step.Version} { + violations = append(violations, validateSafeRuntimeValue(stepPrefix+"."+field, value)...) + } + if step.DownloadRef != "" { + parsed, err := url.Parse(step.DownloadRef) + host := "" + if parsed != nil { + host = strings.ToLower(parsed.Hostname()) + } + ip := net.ParseIP(host) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) { + violations = append(violations, stepPrefix+".downloadRef must be a credential-free HTTPS URL") + } + } + if step.Checksum != "" { + encoded := strings.TrimPrefix(step.Checksum, "sha256:") + if !strings.HasPrefix(step.Checksum, "sha256:") || len(encoded) != 64 { + violations = append(violations, stepPrefix+".checksum is invalid") + } else if _, err := hex.DecodeString(encoded); err != nil { + violations = append(violations, stepPrefix+".checksum is invalid") + } + } + switch step.Type { + case "package": + if !oneOf(step.PackageManager, "winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew") { + violations = append(violations, stepPrefix+".packageManager is unsupported for package step") + } + if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+@/-]{0,119}$`).MatchString(step.PackageName) { + violations = append(violations, stepPrefix+".packageName is invalid") + } + case "verified-download": + if step.DownloadRef == "" || step.Checksum == "" { + violations = append(violations, stepPrefix+" requires downloadRef and checksum") + } + case "steamcmd-app": + if step.PackageManager != "" && step.PackageManager != "steamcmd" || !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(step.PackageName) { + violations = append(violations, stepPrefix+" requires a numeric Steam app and steamcmd adapter") + } + case "manual": + if step.DownloadRef != "" || step.Checksum != "" || step.PackageName != "" { + violations = append(violations, stepPrefix+" manual step cannot contain machine execution fields") + } + } + } + violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...) + } + for i, source := range profiles.LogSources { + prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", source.Key)...) + violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...) + if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") { + violations = append(violations, prefix+".kind is invalid") + } + if source.TargetKey != "" { + violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...) + } + violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...) + if source.CursorKind != "" && !oneOf(source.CursorKind, "sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor") { + violations = append(violations, prefix+".cursorKind is invalid") + } + if source.RetentionDays < 0 || source.RetentionDays > 365 { + violations = append(violations, prefix+".retentionDays is invalid") + } + } + for i, transport := range profiles.TransportProfiles { + prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...) + violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...) + if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon") { + violations = append(violations, prefix+".kind is invalid") + } + if transport.TargetKey != "" { + violations = append(violations, validateProfileKey(prefix+".targetKey", transport.TargetKey)...) + } + if len(transport.Capabilities) == 0 { + violations = append(violations, prefix+".capabilities must not be empty") + } + for j, capability := range transport.Capabilities { + if !validPluginRunCapability(capability) { + violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j)) + } + } + violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...) + } + for i, manager := range profiles.ClientManagers { + prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i) + violations = append(violations, validateProfileKey(prefix+".key", manager.Key)...) + violations = append(violations, recordRuntimeProfileKey(managerKeys, prefix+".key", manager.Key)...) + parsed, err := url.Parse(manager.RepositoryURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") { + violations = append(violations, prefix+".repository.url must be a credential-free HTTPS .git URL") + } + if !oneOf(manager.RevisionPolicy, "pinned", "branch", "tag") { + violations = append(violations, prefix+".repository.revisionPolicy is invalid") + } + switch manager.RevisionPolicy { + case "pinned": + if manager.Revision == "" { + violations = append(violations, prefix+".repository.revision is required for pinned policy") + } + case "branch": + if manager.Branch == "" { + violations = append(violations, prefix+".repository.branch is required for branch policy") + } + case "tag": + if manager.Tag == "" { + violations = append(violations, prefix+".repository.tag is required for tag policy") + } + } + if !oneOf(manager.BuildSystem, "go", "npm", "cargo", "make") { + violations = append(violations, prefix+".build.system is invalid") + } + if len(manager.SupportedTargets) == 0 { + violations = append(violations, prefix+".supportedTargets must not be empty") + } + if len(manager.OutputArtifacts) == 0 { + violations = append(violations, prefix+".outputArtifacts must not be empty") + } + for field, value := range map[string]string{"displayName": manager.DisplayName, "branch": manager.Branch, "tag": manager.Tag, "revision": manager.Revision, "workspaceRef": manager.WorkspaceRef, "entryRef": manager.EntryRef} { + violations = append(violations, validateSafeRuntimeValue(prefix+"."+field, value)...) + } + targets := map[string]struct{}{} + for j, target := range manager.SupportedTargets { + if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") { + violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j)) + } + targetKey := target.OS + "/" + target.Arch + if _, exists := targets[targetKey]; exists { + violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is duplicated", prefix, j)) + } + targets[targetKey] = struct{}{} + } + configKeys := map[string]struct{}{} + for j, config := range manager.ConfigTemplates { + violations = append(violations, validateProfileKey(fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...) + violations = append(violations, recordRuntimeProfileKey(configKeys, fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...) + violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.templateRef", config.TemplateRef)...) + violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.outputRef", config.OutputRef)...) + } + for j, output := range manager.OutputArtifacts { + violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.outputArtifacts[%d]", prefix, j), output)...) + } + violations = append(violations, duplicateViolations(prefix+".outputArtifacts", manager.OutputArtifacts)...) + if manager.Deployment.Mode != "" { + if manager.Deployment.Mode != "run-supervised" { + violations = append(violations, prefix+".deployment.mode is invalid") + } + if !validSemanticVersion(manager.Version) { + violations = append(violations, prefix+".version must be semantic when deployment is declared") + } + violations = append(violations, validateSafeRelativeRuntimePath(prefix+".deployment.executableRef", manager.Deployment.ExecutableRef)...) + if !containsString(manager.OutputArtifacts, manager.Deployment.ExecutableRef) { + violations = append(violations, prefix+".deployment.executableRef must name an output artifact") + } + for j, argument := range manager.Deployment.Arguments { + if !regexp.MustCompile(`^[A-Za-z0-9_./:=@+-]{1,120}$`).MatchString(argument) { + violations = append(violations, fmt.Sprintf("%s.deployment.arguments[%d] is invalid", prefix, j)) + } + violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.deployment.arguments[%d]", prefix, j), argument)...) + } + if len(manager.Deployment.RequiredRunCapabilities) == 0 || !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy) { + violations = append(violations, prefix+".deployment.requiredRunCapabilities must include client-manager.deploy") + } + for j, capability := range manager.Deployment.RequiredRunCapabilities { + if !oneOf(capability, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall) { + violations = append(violations, fmt.Sprintf("%s.deployment.requiredRunCapabilities[%d] is invalid", prefix, j)) + } + } + violations = append(violations, duplicateViolations(prefix+".deployment.requiredRunCapabilities", manager.Deployment.RequiredRunCapabilities)...) + if len(manager.Lifecycle.Actions) == 0 || manager.Lifecycle.StartupTimeoutSeconds < 1 || manager.Lifecycle.StartupTimeoutSeconds > 300 || manager.Lifecycle.StopTimeoutSeconds < 1 || manager.Lifecycle.StopTimeoutSeconds > 120 { + violations = append(violations, prefix+".lifecycle actions and bounded timeouts are required") + } + for j, action := range manager.Lifecycle.Actions { + if !oneOf(action, "start", "stop", "restart", "status", "update", "rollback", "uninstall") { + violations = append(violations, fmt.Sprintf("%s.lifecycle.actions[%d] is invalid", prefix, j)) + } + } + violations = append(violations, duplicateViolations(prefix+".lifecycle.actions", manager.Lifecycle.Actions)...) + if containsAny(manager.Lifecycle.Actions, []string{"start", "stop", "restart", "status"}) && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerControl) { + violations = append(violations, prefix+".lifecycle control actions require client-manager.control") + } + if containsString(manager.Lifecycle.Actions, "update") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUpdate) { + violations = append(violations, prefix+".lifecycle update requires client-manager.update") + } + if containsString(manager.Lifecycle.Actions, "rollback") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerRollback) { + violations = append(violations, prefix+".lifecycle rollback requires client-manager.rollback") + } + if containsString(manager.Lifecycle.Actions, "uninstall") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUninstall) { + violations = append(violations, prefix+".lifecycle uninstall requires client-manager.uninstall") + } + if !oneOf(manager.Health.Mode, "component-heartbeat", "process") || manager.Health.IntervalSeconds < 5 || manager.Health.IntervalSeconds > 300 || manager.Health.DegradedAfterSeconds < manager.Health.IntervalSeconds*2 || manager.Health.OfflineAfterSeconds <= manager.Health.DegradedAfterSeconds || manager.Health.OfflineAfterSeconds > 3600 { + violations = append(violations, prefix+".health mode and thresholds are invalid") + } + for j, capability := range manager.Health.RequiredCapabilities { + if !oneOf(capability, "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream") { + violations = append(violations, fmt.Sprintf("%s.health.requiredCapabilities[%d] is invalid", prefix, j)) + } + } + if manager.Health.Mode == "component-heartbeat" && !containsAny(manager.Health.RequiredCapabilities, []string{"component.register"}) || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.heartbeat") || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.health") { + violations = append(violations, prefix+".health component-heartbeat requires register, heartbeat, and health capabilities") + } + minimum, minimumOK := semanticVersionTuple(manager.Compatibility.MinimumVersion) + maximum, maximumOK := semanticVersionTuple(manager.Compatibility.MaximumVersion) + version, _ := semanticVersionTuple(manager.Version) + if manager.Compatibility.MinimumVersion != "" && !minimumOK || manager.Compatibility.MaximumVersion != "" && !maximumOK || minimumOK && maximumOK && compareSemanticVersion(minimum, maximum) > 0 || minimumOK && compareSemanticVersion(version, minimum) < 0 || maximumOK && compareSemanticVersion(version, maximum) > 0 { + violations = append(violations, prefix+".compatibility version bounds are invalid") + } + if manager.UpdatePolicy.Strategy != "manual-staged" || !manager.UpdatePolicy.RequireApproval || !manager.UpdatePolicy.RetainPrevious || manager.UpdatePolicy.HealthConfirmationSeconds < manager.Health.IntervalSeconds || manager.UpdatePolicy.HealthConfirmationSeconds > 600 { + violations = append(violations, prefix+".updatePolicy must be approved, staged, health checked, and retain previous") + } + } + } + for i, profile := range profiles.LifecycleProfiles { + for _, key := range profile.TransportKeys { + if _, ok := transportKeys[key]; !ok { + violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].transportKeys references undeclared transport %q", i, key)) + } + } + if profile.ClientManagerRef != "" { + if _, ok := managerKeys[profile.ClientManagerRef]; !ok { + violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].clientManagerRef references undeclared client manager", i)) + } + } + } + return finish(violations) +} + +func validateProfileKey(field, value string) []string { + if strings.TrimSpace(value) == "" { + return []string{field + " is required"} + } + if !validDistributionLogicalKey(value) { + return []string{field + " is invalid"} + } + return nil +} + +func validateRuntimePlatforms(field string, platforms []string) []string { + var violations []string + for i, platform := range platforms { + if !validPluginSupportedOS(platform) { + violations = append(violations, fmt.Sprintf("%s[%d] is invalid", field, i)) + } + } + return append(violations, duplicateViolations(field, platforms)...) +} + +func validateSafeRuntimeValue(field, value string) []string { + if value == "" { + return nil + } + lowered := strings.ToLower(value) + if strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "://") || containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "..") || strings.ContainsAny(value, "\r\n") || strings.Contains(lowered, "bash -c") || strings.Contains(lowered, "powershell -") || strings.Contains(lowered, "cmd.exe") || strings.Contains(lowered, "curl |") { + return []string{field + " contains unsafe runtime content"} + } + return nil +} + +func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string { + if key == "" { + return nil + } + if _, exists := seen[key]; exists { + return []string{field + " is duplicated"} + } + seen[key] = struct{}{} + return nil +} + +func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRuntimeProfiles, declared []string) []string { + declaredSet := map[string]struct{}{} + for _, capability := range declared { + declaredSet[capability] = struct{}{} + } + var violations []string + check := func(field string, capabilities []string) { + for i, capability := range capabilities { + if _, ok := declaredSet[capability]; !ok { + violations = append(violations, fmt.Sprintf("%s[%d] must also be declared in manifest capabilities", field, i)) + } + } + } + for i, profile := range profiles.LifecycleProfiles { + check(fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].capabilities", i), profile.Capabilities) + } + for i, transport := range profiles.TransportProfiles { + check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities) + } + for i, manager := range profiles.ClientManagers { + check(fmt.Sprintf("runtimeProfiles.clientManagers[%d].deployment.requiredRunCapabilities", i), manager.Deployment.RequiredRunCapabilities) + } + return violations +} + +func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string { + var violations []string + for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} { + if value != "" && !safeRelativeJSONRef(value) { + violations = append(violations, "runtime actionRefs."+field+" must be a safe relative JSON reference") + } + } + return violations +} + +func oneOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func validateSafeRelativeRuntimePath(field, value string) []string { + if strings.TrimSpace(value) == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "..") || strings.Contains(value, "://") || strings.ContainsAny(value, "\r\n|;&`$<>") || len(value) >= 2 && value[1] == ':' || !regexp.MustCompile(`^[A-Za-z0-9_./-]{1,160}$`).MatchString(value) { + return []string{field + " must be a safe relative path"} + } + return nil +} + +func validSemanticVersion(value string) bool { + _, ok := semanticVersionTuple(value) + return ok +} + +func semanticVersionTuple(value string) ([3]int, bool) { + match := regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$`).FindStringSubmatch(value) + if match == nil { + return [3]int{}, false + } + var result [3]int + for i := 0; i < 3; i++ { + if _, err := fmt.Sscanf(match[i+1], "%d", &result[i]); err != nil { + return [3]int{}, false + } + } + return result, true +} + +func compareSemanticVersion(left, right [3]int) int { + for i := 0; i < 3; i++ { + if left[i] < right[i] { + return -1 + } + if left[i] > right[i] { + return 1 + } + } + return 0 +} diff --git a/platform/validator/server_lifecycle.go b/platform/validator/server_lifecycle.go index b113bcc..06e4e0c 100644 --- a/platform/validator/server_lifecycle.go +++ b/platform/validator/server_lifecycle.go @@ -15,6 +15,7 @@ func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error { violations = appendRequired(violations, "pluginId", create.PluginID) violations = appendRequired(violations, "runEndpointId", create.RunEndpointID) violations = appendRequired(violations, "name", create.Name) + violations = appendRequired(violations, "profileKey", create.ProfileKey) violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey) return finish(violations) } @@ -31,7 +32,7 @@ func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error { switch action { - case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop: + case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus: return nil default: return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}} diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index cab7831..473f5a9 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -57,7 +57,7 @@ async function main() { const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin"); const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin"); const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user"); - const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyRef?.startsWith("secret://"), "redacted AI provider"); + const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyConfigured === true, "redacted AI provider"); assertEqual(server.pluginId, "game.example", "server is backed by game.example"); assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug"); @@ -203,7 +203,7 @@ async function loginApi() { const response = await postJson("/auth/login", { account: "operator.local@example.test", password: "operator-local" - }); + }, { "X-Auth-Token-Response": "bearer" }); if (!response.sessionId || response.status !== "authenticated") { throw new Error("local debug API login did not return an active session"); } diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 6961aca..01dee23 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -1,14 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { PlatformApiClient, setPlatformApiSessionToken } from "./client"; +import { PlatformApiClient, PlatformApiError, setPlatformApiAuthFailureHandler, setPlatformApiSessionToken } from "./client"; import type { AiProviderResponse, ArtifactDownloadReferenceResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types"; +const runtimeDigest = `sha256:${"a".repeat(64)}`; + const provider: AiProviderResponse = { id: "ai.openai", name: "OpenAI", kind: "openai", baseUrl: "https://api.openai.com/v1", - apiKeyRef: "secret://providers/openai", + apiKeyConfigured: true, models: ["gpt-4.1"], defaultModel: "gpt-4.1", relayMode: "direct", @@ -33,6 +35,7 @@ const plugin: GamePluginResponse = { pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], + runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] }, status: "installed" }; @@ -90,6 +93,9 @@ const job: JobResponse = { idempotencyKey: "idem-start", state: "queued", progress: { percent: 0, message: "queued" }, + retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 }, + attempt: 0, + reconcileCount: 0, createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }; @@ -139,6 +145,7 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = { describe("PlatformApiClient AI providers", () => { afterEach(() => { setPlatformApiSessionToken(null); + setPlatformApiAuthFailureHandler(null); vi.restoreAllMocks(); }); @@ -170,8 +177,21 @@ describe("PlatformApiClient AI providers", () => { const client = new PlatformApiClient(); await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 }); - await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id }); - await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" }); + const providerRequest = { + id: provider.id, + name: provider.name, + kind: provider.kind, + baseUrl: provider.baseUrl, + apiKeyRef: "secret://providers/openai", + models: provider.models, + defaultModel: provider.defaultModel, + relayMode: provider.relayMode, + timeoutMs: provider.timeoutMs, + redactionPolicy: provider.redactionPolicy + }; + await expect(client.createAiProvider(providerRequest)).resolves.toMatchObject({ id: provider.id }); + const { id: _id, ...providerUpdate } = providerRequest; + await expect(client.updateAiProvider(provider.id, { ...providerUpdate, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" }); await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" }); await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" }); await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] }); @@ -343,6 +363,9 @@ describe("PlatformApiClient AI providers", () => { if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") { return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } }); } + if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") { + return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", auditSummary: "private supervised process identity" } } }); + } if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) { return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 }); } @@ -356,6 +379,13 @@ describe("PlatformApiClient AI providers", () => { if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) { return jsonResponse(runtimeActions); } + if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && (!init?.method || init.method === "GET")) { + return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" }); + } + if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && init?.method === "PUT") { + expect(JSON.parse(String(init.body))).toEqual({ profileKey: "local", bindings: { "server-root": "runtime.server-root" } }); + return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" }); + } if (url.endsWith("/api/v1/server-instances/server-1/run/generate") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" }); return jsonResponse({ @@ -392,20 +422,49 @@ describe("PlatformApiClient AI providers", () => { }); } if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" }); + expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" }); return jsonResponse({ id: "run-update-1", serverInstanceId: server.id, runEndpointId: endpoint.id, artifactId: "artifact-run-1", - checksum: "sha256:runchecksum", + checksum: runtimeDigest, + targetOs: "linux", + targetArch: "amd64", + targetRelease: "run-dist-2", + previousVersion: "0.1.0", jobId: "job-run-update", idempotencyKey: "idem-run-update", status: "queued", + phase: "queued", + rollback: false, createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); } + if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "GET") { + return jsonResponse({ + items: [{ + id: "run-update-1", + serverInstanceId: server.id, + runEndpointId: endpoint.id, + artifactId: "artifact-run-1", + checksum: runtimeDigest, + targetOs: "linux", + targetArch: "amd64", + targetRelease: "run-dist-2", + previousVersion: "0.1.0", + jobId: "job-run-update", + status: "running", + phase: "restart-requested", + message: "verified update staged; restart requested", + rollback: false, + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:01:00Z" + }], + count: 1 + }); + } if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ profileKey: "scum-client-manager", @@ -457,8 +516,21 @@ describe("PlatformApiClient AI providers", () => { expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" }); return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" }); } + if (url.endsWith("/api/v1/server-instances/server-1/dependencies") && init?.method === "GET") { + return jsonResponse({ + serverInstanceId: server.id, + pluginId: plugin.id, + pluginVersion: plugin.version, + profileKey: "local", + targetOs: "linux", + targetArch: "amd64", + probes: [{ key: "java-21", kind: "java.version", required: true, state: "missing", installPlanKey: "install-java-linux" }], + plans: [{ key: "install-java-linux", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest: runtimeDigest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }], + updatedAt: "2026-07-03T00:00:00Z" + }); + } if (url.endsWith("/api/v1/server-instances/server-1/dependencies/install") && init?.method === "POST") { - expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" }); + expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" }); return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" }); } if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) { @@ -546,11 +618,12 @@ describe("PlatformApiClient AI providers", () => { await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] }); await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true }); await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum }); - await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({ + await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create", profileKey: "local", bindings: {} })).resolves.toMatchObject({ action: "create" }); await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" }); await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" }); + await expect(client.queryServerProcessStatus(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-status" })).resolves.toMatchObject({ action: "status", job: { executionResult: { processState: "running" } } }); await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 }); await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] }); await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] }); @@ -558,6 +631,8 @@ describe("PlatformApiClient AI providers", () => { const runtime = await client.getServerRuntimeActions(server.id); expect(runtime.runStatus).toBe("online"); expect(runtime.actions.some((action) => action.key === "generate-run" && action.available)).toBe(true); + await expect(client.getServerRuntimeBinding(server.id)).resolves.toMatchObject({ profileKey: "local", status: "complete", keys: [{ key: "server-root", configured: true }] }); + await expect(client.updateServerRuntimeBinding(server.id, { profileKey: "local", bindings: { "server-root": "runtime.server-root" } })).resolves.toMatchObject({ status: "complete" }); await expect(client.generateRunDistribution(server.id, { targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" })).resolves.toMatchObject({ artifactId: "artifact-run-1", keyGeneration: 1, @@ -565,10 +640,11 @@ describe("PlatformApiClient AI providers", () => { }); await expect(client.downloadLatestRunDistribution(server.id)).resolves.toMatchObject({ artifactId: "artifact-run-1", rangeSupported: true }); await expect(client.resetRunKey(server.id)).resolves.toMatchObject({ componentKind: "run", generation: 2 }); - await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" })).resolves.toMatchObject({ + await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" })).resolves.toMatchObject({ jobId: "job-run-update", status: "queued" }); + await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] }); await expect( client.generateClientManager(server.id, { profileKey: "scum-client-manager", @@ -582,7 +658,8 @@ describe("PlatformApiClient AI providers", () => { await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" }); await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 }); await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" }); - await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({ + await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] }); + await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({ capability: "dependencies.install" }); await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 }); @@ -599,7 +676,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(38); + expect(fetchMock).toHaveBeenCalledTimes(43); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { @@ -651,7 +728,8 @@ describe("PlatformApiClient AI providers", () => { it("keeps raw key fields out of provider responses", () => { expect("apiKey" in provider).toBe(false); expect("rawApiKey" in provider).toBe(false); - expect(provider.apiKeyRef).toBe("secret://providers/openai"); + expect(provider.apiKeyConfigured).toBe(true); + expect("apiKeyRef" in provider).toBe(false); }); it("calls auth endpoints and attaches bearer sessions", async () => { @@ -687,6 +765,45 @@ describe("PlatformApiClient AI providers", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + + it("resets 401 sessions and redacts auth error details", async () => { + const onAuthFailure = vi.fn(); + setPlatformApiSessionToken("expired-session-token"); + setPlatformApiAuthFailureHandler(onAuthFailure); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + code: "unauthorized", + message: "expired secret://internal/provider raw-token-value /srv/game unix:///tmp/run.sock" + }), { status: 401, headers: { "Content-Type": "application/json" } }))); + + const client = new PlatformApiClient(); + const failure = await client.getCurrentUser().catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(PlatformApiError); + expect(failure).toMatchObject({ status: 401, code: "unauthorized", message: "会话已失效,请重新登录。" }); + expect(String(failure)).not.toMatch(/secret:\/\/|raw-token-value|\/srv\/game|unix:\/\//); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + + it("keeps 403 as a safe capability denial without clearing the session", async () => { + const onAuthFailure = vi.fn(); + setPlatformApiAuthFailureHandler(onAuthFailure); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer owner-session"); + return new Response(JSON.stringify({ code: "forbidden", message: "owner mismatch secret://internal" }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PlatformApiClient("/api/v1", () => "owner-session"); + await expect(client.getServerInstance("other-server")).rejects.toMatchObject({ + status: 403, + code: "forbidden", + message: "没有权限访问该资源。" + }); + expect(onAuthFailure).not.toHaveBeenCalled(); + }); }); function jsonResponse(body: unknown): Response { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 2fe3c07..788a1d3 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -21,6 +21,7 @@ import type { ComponentKeyResponse, ComponentKeyResetRequest, CurrentUserResponse, + DependencyCatalogResponse, DependencyJobRequest, FileOperationDispatchRequest, FileOperationDispatchResponse, @@ -50,7 +51,10 @@ import type { RunDistributionResponse, RunEndpointListResponse, RunUpdateJobResponse, + RunUpdateJobListResponse, RunUpdateRequest, + RuntimeBindingResponse, + RuntimeBindingUpdateRequest, ServerConfigResponse, ServerConfigDiffPreviewRequest, ServerConfigDiffPreviewResponse, @@ -65,6 +69,12 @@ import type { ServerMemberListResponse, ServerMemberRequest, ServerMetricsListResponse, + MetricSampleListResponse, + BackupListResponse, + BackupResponse, + RemoteAdapterDeclarationListResponse, + RemoteAdapterRequest, + RemoteAdapterResponse, ServerRuntimeActionsResponse, UserCreateRequest, UserListResponse, @@ -75,13 +85,30 @@ import type { UserUpdateRequest } from "./types"; import { readWebRuntimeEnv } from "../schemas/env"; +import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates"; let platformApiSessionToken: string | null = null; +let platformApiAuthFailureHandler: ((error: PlatformApiError) => void) | null = null; export function setPlatformApiSessionToken(token: string | null) { platformApiSessionToken = token; } +export function setPlatformApiAuthFailureHandler(handler: ((error: PlatformApiError) => void) | null) { + platformApiAuthFailureHandler = handler; +} + +export class PlatformApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string + ) { + super(message); + this.name = "PlatformApiError"; + } +} + export class PlatformApiClient { constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {} @@ -119,6 +146,14 @@ export class PlatformApiClient { }); } + async getServerRuntimeBinding(id: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/runtime-binding`); + } + + async updateServerRuntimeBinding(id: string, request: RuntimeBindingUpdateRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/runtime-binding`, { method: "PUT", body: request }); + } + async startServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/start`, { method: "POST", @@ -126,12 +161,19 @@ export class PlatformApiClient { }); } - async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { + async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/stop`, { method: "POST", body: request }); - } + } + + async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/process/status`, { + method: "POST", + body: request + }); + } async listServerAdministratorCandidates(id: string): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/administrators/candidates`); @@ -177,10 +219,9 @@ export class PlatformApiClient { if (sessionToken) { headers.set("Authorization", `Bearer ${sessionToken}`); } - const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers }); + const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers, credentials: "same-origin" }); if (!response.ok) { - const apiError = await safeReadError(response); - throw new Error(apiError?.message ?? `request failed: ${response.status}`); + throw await responseError(response); } const payload = await response.arrayBuffer(); return { @@ -225,10 +266,15 @@ export class PlatformApiClient { } async pushRunUpdate(id: string, request: RunUpdateRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(id)}/run/update`, { + const response = await this.request(`/server-instances/${encodeURIComponent(id)}/run/update`, { method: "POST", body: request }); + return parseSafeRunUpdate(response); + } + + async listRunUpdates(id: string): Promise { + return parseSafeRunUpdateList(await this.request(`/server-instances/${encodeURIComponent(id)}/run/update`)); } async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise { @@ -259,6 +305,10 @@ export class PlatformApiClient { }); } + async getDependencyCatalog(id: string): Promise { + return parseSafeDependencyCatalog(await this.request(`/server-instances/${encodeURIComponent(id)}/dependencies`)); + } + async installDependencies(id: string, request: DependencyJobRequest): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/dependencies/install`, { method: "POST", @@ -340,6 +390,27 @@ export class PlatformApiClient { return this.request("/metrics/server-instances"); } + async listMetricHistory(serverInstanceId: string, limit = 100): Promise { + const params = new URLSearchParams({ serverInstanceId, limit: String(limit) }); + return this.request(`/metrics/server-instances/history?${params.toString()}`); + } + + async listBackups(serverInstanceId: string): Promise { + return this.request(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`); + } + + async getBackup(id: string): Promise { + return this.request(`/backups/${encodeURIComponent(id)}`); + } + + async listRemoteAdapters(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`); + } + + async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); + } + async getServerConfig(id: string): Promise { return this.request(`/server-instances/${encodeURIComponent(id)}/config`); } @@ -446,14 +517,14 @@ export class PlatformApiClient { const response = await fetch(options.absolute ? path : `${this.baseUrl}${path}`, { ...options.init, + credentials: options.init?.credentials ?? "same-origin", method: options.method ?? options.init?.method ?? "GET", headers, body: options.body === undefined ? options.init?.body : JSON.stringify(options.body) }); if (!response.ok) { - const apiError = await safeReadError(response); - throw new Error(apiError?.message ?? `request failed: ${response.status}`); + throw await responseError(response); } if (options.parseJson === false || response.status === 204) { @@ -480,6 +551,21 @@ async function safeReadError(response: Response): Promise { + const apiError = await safeReadError(response); + const message = response.status === 401 + ? "会话已失效,请重新登录。" + : response.status === 403 + ? "没有权限访问该资源。" + : apiError?.message ?? `request failed: ${response.status}`; + const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message); + if (response.status === 401) { + platformApiSessionToken = null; + platformApiAuthFailureHandler?.(error); + } + return error; +} + function marketplaceQuery(filter: MarketplacePluginFilterRequest): string { const params = new URLSearchParams(); if (filter.status && filter.status !== "all") { diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index 73a7162..adc943e 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -16,9 +16,14 @@ API clients and DTO types live here, not inside page components. Every API client must use named request and response types. +`PlatformApiClient` converts 401 into a safe re-login error, clears its in-memory bearer token, and notifies the session store to remove browser persistence. A 403 remains a safe capability/ownership denial and does not disclose server error details. Neither error path renders tokens, secret refs, paths, or sockets. + +Normal browser login uses the platform's HttpOnly SameSite cookie and `credentials=same-origin`; the JSON response does not expose a session token. Reading an older localStorage bearer remains a migration compatibility path only, and any 401 removes it. + ## Server Management Workflows -- `createServerWorkflow` posts `ServerLifecycleCreateRequest` to `/server-instances/workflows/create` and receives the accepted instance plus install job. +- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with a declared `profileKey` and initial logical `bindings` to `/server-instances/workflows/create`, and receives the accepted instance plus install job only after binding validation. +- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. - `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API. @@ -27,15 +32,21 @@ Every API client must use named request and response types. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. - `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. - `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details. -- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and pending lifecycle status. +- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count. +- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs. +- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded audit message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health. +- `listMetricHistory`, `listBackups`, and `getBackup` read bounded owner-scoped metric and backup projections. Backup responses contain artifact IDs/checksums and recovery/retention state only; they never include body bytes or storage paths. +- `listRemoteAdapters` and `requestRemoteAdapter` use declaration-backed logical target keys and return queued status/result references. The browser never receives adapter credentials, host addresses, sockets, Run tokens, leases, session hashes, or secret refs. - Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys. +- Server creation and detail forms derive profile choices and binding fields from `GamePluginResponse.runtimeProfiles`; they must not hardcode a complete state or game-specific machine paths. +- AI provider responses expose `apiKeyConfigured` only. Existing secret refs are never rehydrated into edit forms; a blank update preserves the platform-owned secret reference. ## Redesign Contract Gaps (redesign-platform-web-interactions) Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently: - `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges. -- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`) and `POST /api/v1/auth/logout`: implemented bearer session lifecycle for authenticated workspace entry. +- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`), `POST /api/v1/auth/rotate`, and `POST /api/v1/auth/logout`: implemented bounded, durable bearer session lifecycle for authenticated workspace entry. - `GET /api/v1/users/current` (`CurrentUserResponse`): implemented current session identity, roles, profile summary, and theme preference reference for role-aware navigation and default landing. - `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields. - `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference. @@ -47,4 +58,6 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata - `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. - Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals. - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent. + +Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. - Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index e36d4d8..3a0bc55 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -8,8 +8,10 @@ export interface HealthResponse { export type GamePluginStatus = "installed" | "disabled" | "invalid" | "updating"; export type ServerInstanceState = "draft" | "installing" | "ready" | "running" | "stopped" | "failed" | "deleted"; export type RunEndpointStatus = "online" | "offline" | "degraded" | "disabled"; -export type JobState = "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled"; -export type ServerLifecycleAction = "create" | "start" | "stop"; +export type JobState = "queued" | "accepted" | "running" | "retrying" | "succeeded" | "failed" | "cancelled"; +export type DependencyState = "unknown" | "present" | "missing" | "installing" | "failed"; +export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed"; +export type ServerLifecycleAction = "create" | "start" | "stop" | "status"; export interface PluginPermissionsResponse { ai: boolean; @@ -17,6 +19,7 @@ export interface PluginPermissionsResponse { files: boolean; jobs: boolean; artifacts: boolean; + remoteAccess?: boolean; } export interface GamePluginPageResponse { @@ -27,6 +30,77 @@ export interface GamePluginPageResponse { bridgeActions?: string[]; } +export interface RuntimeDiscoveryProbeResponse { + key: string; + kind: string; + targetKey: string; + required?: boolean; + expected?: string; + platforms?: string[]; +} + +export interface RuntimeLifecycleProfileResponse { + key: string; + mode: "local-process" | "hosted-ftp-rcon" | "ftp-only" | "custom-client"; + capabilities: string[]; + actionRefs?: Record; + transportKeys?: string[]; + clientManagerRef?: string; + platforms?: string[]; +} + +export interface RuntimeDependencyProbeResponse { + key: string; + kind: string; + targetKey: string; + required?: boolean; + minimumVersion?: string; + platforms?: string[]; +} + +export interface RuntimeInstallPlanResponse { + key: string; + title: string; + platforms?: string[]; + steps: Array<{ type: string; targetKey: string; packageManager?: string; packageName?: string; version?: string; downloadRef?: string; checksum?: string }>; +} + +export interface RuntimeLogSourceResponse { + key: string; + kind: string; + targetKey?: string; + streamKey: string; + cursorKind?: string; + retentionDays?: number; +} + +export interface RuntimeTransportProfileResponse { + key: string; + kind: string; + targetKey?: string; + capabilities: string[]; +} + +export interface RuntimeClientManagerProfileResponse { + key: string; + displayName?: string; + repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string }; + supportedTargets: Array<{ os: string; arch: string }>; + build: { system: string; workspaceRef?: string; entryRef?: string }; + configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>; + outputArtifacts: string[]; +} + +export interface GamePluginRuntimeProfilesResponse { + discovery?: RuntimeDiscoveryProbeResponse[]; + lifecycleProfiles?: RuntimeLifecycleProfileResponse[]; + dependencyProbes?: RuntimeDependencyProbeResponse[]; + installPlans?: RuntimeInstallPlanResponse[]; + logSources?: RuntimeLogSourceResponse[]; + transportProfiles?: RuntimeTransportProfileResponse[]; + clientManagers?: RuntimeClientManagerProfileResponse[]; +} + export interface GamePluginResponse { id: string; name: string; @@ -46,6 +120,7 @@ export interface GamePluginResponse { tags: string[]; aiPurposes: string[]; validationViolations?: string[]; + runtimeProfiles?: GamePluginRuntimeProfilesResponse; status: GamePluginStatus; } @@ -104,7 +179,10 @@ export interface ServerInstanceResponse { ownerUserId?: string; adminUserIds: string[]; state: ServerInstanceState; - configVersion: number; + configVersion: number; + configKey?: string; + configChecksum?: string; + configUpdatedAt?: string; createdAt: string; updatedAt: string; } @@ -124,10 +202,39 @@ export interface ServerLifecycleCreateRequest { runEndpointId: string; name: string; idempotencyKey: string; + profileKey: string; + bindings: Record; +} + +export interface RuntimeBindingUpdateRequest { + profileKey: string; + bindings: Record; +} + +export interface RuntimeBindingKeyResponse { + key: string; + required: boolean; + configured: boolean; + secret: boolean; +} + +export interface RuntimeBindingResponse { + serverInstanceId: string; + pluginId: string; + profileKey?: string; + mode?: string; + configured: boolean; + keys: RuntimeBindingKeyResponse[]; + missingKeys: string[]; + status: "complete" | "incomplete"; + reason?: string; + createdAt?: string; + updatedAt?: string; } export interface ServerLifecycleCommandRequest { - expectedConfigVersion: number; + expectedConfigVersion: number; + expectedChecksum?: string; idempotencyKey: string; } @@ -149,6 +256,8 @@ export interface RunEndpointResponse { id: string; displayName: string; version: string; + platform?: string; + architecture?: string; status: RunEndpointStatus; capabilities: string[]; capacity: RunCapacityResponse; @@ -175,11 +284,39 @@ export interface JobResponse { idempotencyKey: string; state: JobState; progress: JobProgressBody; - resultRef?: string; + resultRef?: string; + executionResult?: JobExecutionResultResponse; + retryPolicy: { + maxAttempts: number; + initialBackoffSeconds: number; + maxBackoffSeconds: number; + }; + attempt: number; + nextAttemptAt?: string; + ackDeadlineAt?: string; + leaseExpiresAt?: string; + cancelReason?: string; + cancelRequestedAt?: string; + cancelCompletedAt?: string; + terminalAt?: string; + lastReconciledAt?: string; + reconcileCount: number; + reconcileOutcome?: string; createdAt: string; updatedAt: string; } +export interface JobExecutionResultResponse { + kind?: string; + processState?: string; + exitClassification?: string; + exitCode?: number; + version?: number; + checksum?: string; + sizeBytes?: number; + auditSummary?: string; +} + export interface JobListResponse { items: JobResponse[]; count: number; @@ -288,13 +425,25 @@ export interface RunUpdateJobResponse { runEndpointId: string; artifactId: string; checksum: string; + targetOs: string; + targetArch: string; + targetRelease?: string; + previousVersion?: string; jobId?: string; idempotencyKey?: string; status: string; + phase: RunUpdatePhase; + message?: string; + rollback: boolean; createdAt: string; updatedAt: string; } +export interface RunUpdateJobListResponse { + items: RunUpdateJobResponse[]; + count: number; +} + export interface ClientManagerBuildRequest { profileKey: string; targetOs: string; @@ -351,9 +500,51 @@ export interface DependencyJobRequest { installPlanKey?: string; targetOs?: string; targetArch?: string; + planDigest?: string; idempotencyKey?: string; } +export interface DependencyProbeViewResponse { + key: string; + kind: string; + required: boolean; + minimumVersion?: string; + state: DependencyState; + evidence?: string; + installPlanKey?: string; +} + +export interface DependencyPlanStepViewResponse { + type: string; + targetKey: string; + packageManager?: string; + packageName?: string; + version?: string; + downloadHost?: string; + sizeBytes?: number; +} + +export interface DependencyPlanViewResponse { + key: string; + title: string; + targetOs: string; + targetArch: string; + digest: string; + steps: DependencyPlanStepViewResponse[]; +} + +export interface DependencyCatalogResponse { + serverInstanceId: string; + pluginId: string; + pluginVersion: string; + profileKey: string; + targetOs: string; + targetArch: string; + probes: DependencyProbeViewResponse[]; + plans: DependencyPlanViewResponse[]; + updatedAt: string; +} + export interface LogBackfillRequest { sourceKey: string; checkpointRef?: string; @@ -435,7 +626,7 @@ export interface AiProviderResponse { name: string; kind: AiProviderKind; baseUrl: string; - apiKeyRef: string; + apiKeyConfigured: boolean; models: string[]; defaultModel?: string; relayMode: AiRelayMode; @@ -555,6 +746,7 @@ export interface AuthSessionResponse { sessionId?: string; status: "authenticated" | "pending"; message?: string; + expiresAt?: string; } export interface UserProfileUpdateRequest { @@ -608,12 +800,79 @@ export interface ServerMetricsListResponse { count: number; } +export interface MetricSampleResponse extends ServerMetricsResponse { + id?: string; + runEndpointId?: string; +} + +export interface MetricSampleListResponse { + items: MetricSampleResponse[]; + count: number; +} + +export type BackupState = "pending" | "available" | "failed" | "expired"; + +export interface BackupResponse { + id: string; + serverInstanceId: string; + artifactId: string; + checksum: string; + sizeBytes: number; + state: BackupState; + recoveryStatus?: string; + retentionUntil?: string; + createdAt: string; + updatedAt: string; +} + +export interface BackupListResponse { + items: BackupResponse[]; + count: number; +} + +export interface RemoteAdapterDeclarationResponse { + key: string; + kind: string; + targetKeys: string[]; + capabilities: string[]; + timeoutSeconds: number; + maxAttempts: number; +} + +export interface RemoteAdapterDeclarationListResponse { + items: RemoteAdapterDeclarationResponse[]; + count: number; +} + +export interface RemoteAdapterRequest { + declarationKey: string; + targetKey: string; + capability: string; + timeoutSeconds?: number; + maxAttempts?: number; + idempotencyKey: string; +} + +export interface RemoteAdapterResponse { + requestId: string; + serverInstanceId: string; + declarationKey: string; + targetKey: string; + kind: string; + status: string; + retryable: boolean; + message: string; + resultRef?: string; + completedAt?: string; +} + export interface ServerConfigResponse { serverInstanceId: string; configVersion: number; format: string; key?: string; - content: string; + content: string; + checksum?: string; source?: string; updatedAt: string; } @@ -628,7 +887,8 @@ export interface ConfigDiffLineResponse { } export interface ServerConfigDiffPreviewRequest { - expectedConfigVersion: number; + expectedConfigVersion: number; + expectedChecksum?: string; key: string; proposedContent?: string; proposedContentInputRef?: string; @@ -636,7 +896,8 @@ export interface ServerConfigDiffPreviewRequest { export interface ServerConfigDiffPreviewResponse { serverInstanceId: string; - configVersion: number; + configVersion: number; + checksum?: string; key: string; currentContent: string; proposedContent?: string; @@ -648,7 +909,8 @@ export interface ServerConfigDiffPreviewResponse { } export interface ServerConfigWriteApprovalRequest { - expectedConfigVersion: number; + expectedConfigVersion: number; + expectedChecksum?: string; key: string; proposedContent?: string; proposedContentInputRef?: string; @@ -668,8 +930,10 @@ export interface FileOperationDispatchRequest { pluginId?: string; operation: FileOperationKind; key: string; - inputRef?: string; - expectedConfigVersion?: number; + inputRef?: string; + content?: string; + expectedConfigVersion?: number; + expectedChecksum?: string; idempotencyKey: string; } diff --git a/platform_web/components/RuntimeTaskProgress.test.ts b/platform_web/components/RuntimeTaskProgress.test.ts new file mode 100644 index 0000000..ccfe482 --- /dev/null +++ b/platform_web/components/RuntimeTaskProgress.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { projectRuntimeTrackedJob, runtimeBuildStages } from "./RuntimeTaskProgress"; + +describe("distribution build job progress", () => { + it("projects worker progress messages onto the real build stage", () => { + const projection = projectRuntimeTrackedJob(runtimeBuildStages, { + id: "job-build-1", + state: "running", + progress: { percent: 65, message: "build_compile: compiling target executable" } + }); + + expect(projection).toMatchObject({ status: "running", percent: 65, currentStageKey: "build_compile" }); + expect(projection.stageStatus).toMatchObject({ git_sync: "completed", env_check: "completed", deps_download: "completed", build_compile: "running", package_finalize: "pending" }); + }); + + it("keeps a failed worker stage failed instead of timer-completing later stages", () => { + const projection = projectRuntimeTrackedJob(runtimeBuildStages, { + id: "job-build-2", + state: "failed", + progress: { percent: 65, message: "build_compile: Go compilation failed" } + }); + + expect(projection).toMatchObject({ status: "failed", percent: 65, currentStageKey: "build_compile" }); + expect(projection.stageStatus.build_compile).toBe("failed"); + expect(projection.stageStatus.package_finalize).toBe("pending"); + }); + + it("marks every stage complete only when the backend job succeeds", () => { + const projection = projectRuntimeTrackedJob(runtimeBuildStages, { + id: "job-build-3", + state: "succeeded", + progress: { percent: 100, message: "package_finalize: build artifact available" } + }); + + expect(projection.status).toBe("succeeded"); + expect(projection.percent).toBe(100); + expect(Object.values(projection.stageStatus)).toEqual(runtimeBuildStages.map(() => "completed")); + }); + + it("keeps durable retry-wait jobs active and exposes the next attempt", () => { + const projection = projectRuntimeTrackedJob(runtimeBuildStages, { + id: "job-build-retry", + state: "retrying", + progress: { percent: 10 }, + attempt: 1, + retryPolicy: { maxAttempts: 3 }, + nextAttemptAt: "2026-07-18T12:00:02Z" + }); + + expect(projection.status).toBe("running"); + expect(projection.message).toContain("第 2 次尝试"); + }); +}); diff --git a/platform_web/components/RuntimeTaskProgress.tsx b/platform_web/components/RuntimeTaskProgress.tsx index cec38ca..d5b5d65 100644 --- a/platform_web/components/RuntimeTaskProgress.tsx +++ b/platform_web/components/RuntimeTaskProgress.tsx @@ -96,8 +96,21 @@ interface RuntimeTaskRunOptions { export interface RuntimeTrackedJob { id: string; - state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled"; + state: "queued" | "accepted" | "running" | "retrying" | "succeeded" | "failed" | "cancelled"; progress: { percent: number; message?: string }; + attempt?: number; + retryPolicy?: { maxAttempts: number }; + nextAttemptAt?: string; + cancelReason?: string; + reconcileOutcome?: string; +} + +export interface RuntimeTrackedJobProjection { + status: RuntimeTaskStatus; + percent: number; + currentStageKey: string; + stageStatus: Record; + message: string; } interface RuntimeTrackedTaskOptions { @@ -242,20 +255,15 @@ export function useRuntimeTaskController() { while (true) { const job = await poll(started.jobId); - const message = job.progress.message?.trim() || job.state; - const stageIndex = trackedStageIndex(stages, message, job.progress.percent); - const stage = stages[stageIndex] ?? stages[0]; - const stageStatus = Object.fromEntries( - stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"]) - ) as Record; + const projection = projectRuntimeTrackedJob(stages, job); setTask((current) => current ? { ...current, - percent: Math.max(current.percent, Math.min(99, job.progress.percent)), - currentStageKey: stage?.key ?? current.currentStageKey, - stageStatus, - logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message) + percent: job.state === "succeeded" ? 100 : Math.max(current.percent, projection.percent), + currentStageKey: projection.currentStageKey || current.currentStageKey, + stageStatus: projection.stageStatus, + logs: current.logs[current.logs.length - 1] === projection.message ? current.logs : appendRuntimeLog(current.logs, projection.message) } : current ); @@ -275,14 +283,15 @@ export function useRuntimeTaskController() { return started.value; } if (job.state === "failed" || job.state === "cancelled") { - const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败"); + const error = projection.message || (job.state === "cancelled" ? "构建已取消" : "构建失败"); setTask((current) => current ? { ...current, status: "failed", error, - stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" }, + currentStageKey: projection.currentStageKey || firstStage, + stageStatus: projection.stageStatus, logs: appendRuntimeLog(current.logs, error) } : current @@ -484,6 +493,27 @@ function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent: return Math.min(index, Math.max(0, stages.length - 1)); } +export function projectRuntimeTrackedJob(stages: RuntimeTaskStage[], job: RuntimeTrackedJob): RuntimeTrackedJobProjection { + const retryLabel = job.state === "retrying" ? `等待第 ${Math.min((job.attempt ?? 0) + 1, job.retryPolicy?.maxAttempts ?? (job.attempt ?? 0) + 1)} 次尝试${job.nextAttemptAt ? `(${new Date(job.nextAttemptAt).toLocaleString()})` : ""}` : ""; + const message = job.cancelReason?.trim() || job.progress.message?.trim() || retryLabel || job.reconcileOutcome?.trim() || job.state; + const stageIndex = trackedStageIndex(stages, message, job.progress.percent); + const currentStageKey = stages[stageIndex]?.key ?? stages[0]?.key ?? "start"; + const terminalFailure = job.state === "failed" || job.state === "cancelled"; + const stageStatus = Object.fromEntries( + stages.map((item, index) => [ + item.key, + job.state === "succeeded" || index < stageIndex ? "completed" : index === stageIndex ? (terminalFailure ? "failed" : "running") : "pending" + ]) + ) as Record; + return { + status: job.state === "succeeded" ? "succeeded" : terminalFailure ? "failed" : "running", + percent: job.state === "succeeded" ? 100 : Math.min(99, Math.max(0, job.progress.percent)), + currentStageKey, + stageStatus, + message + }; +} + function appendRuntimeLog(logs: string[], line: string): string[] { return [...logs, line].slice(-8); } diff --git a/platform_web/contracts/aiProviders.ts b/platform_web/contracts/aiProviders.ts index 2307407..be127a5 100644 --- a/platform_web/contracts/aiProviders.ts +++ b/platform_web/contracts/aiProviders.ts @@ -10,6 +10,7 @@ export interface AiProviderFormState { kind: AiProviderKind; baseUrl: string; apiKeyRef: string; + apiKeyConfigured: boolean; modelsText: string; defaultModel: string; relayMode: AiRelayMode; @@ -155,7 +156,8 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS name: provider.name, kind: provider.kind, baseUrl: provider.baseUrl, - apiKeyRef: provider.apiKeyRef, + apiKeyRef: "", + apiKeyConfigured: provider.apiKeyConfigured, modelsText: provider.models.join(", "), defaultModel: provider.defaultModel ?? "", relayMode: provider.relayMode, @@ -172,6 +174,7 @@ export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderForm kind: defaults.kind, baseUrl: defaults.baseUrl, apiKeyRef: defaults.apiKeyRef, + apiKeyConfigured: false, modelsText: defaults.modelsText, defaultModel: defaults.defaultModel, relayMode: defaults.relayMode, @@ -189,6 +192,7 @@ export function applyAiProviderKindDefaults(current: AiProviderFormState, kind: kind: defaults.kind, baseUrl: defaults.baseUrl, apiKeyRef: defaults.apiKeyRef, + apiKeyConfigured: false, modelsText: defaults.modelsText, defaultModel: defaults.defaultModel, relayMode: defaults.relayMode, @@ -209,7 +213,7 @@ export function completeAiProviderForm(form: AiProviderFormState): AiProviderFor id: generatedAiProviderId(form), name: form.name.trim() || defaults.name, baseUrl: form.baseUrl.trim() || defaults.baseUrl, - apiKeyRef: form.apiKeyRef.trim() || defaults.apiKeyRef, + apiKeyRef: form.apiKeyConfigured && !form.apiKeyRef.trim() ? "" : form.apiKeyRef.trim() || defaults.apiKeyRef, modelsText, defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel, relayMode: form.relayMode || defaults.relayMode, diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index 39aa309..9be5a34 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -15,6 +15,14 @@ export interface ServerCreateFormState { name: string; pluginId: string; runEndpointId: string; + profileKey: string; + bindings: Record; +} + +export interface RuntimeBindingField { + key: string; + required: boolean; + sensitive: boolean; } export interface ServerWorkflowActionState { @@ -46,7 +54,9 @@ export const emptyServerCreateForm: ServerCreateFormState = { id: "", name: "", pluginId: "", - runEndpointId: "" + runEndpointId: "", + profileKey: "", + bindings: {} }; export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary { @@ -85,17 +95,43 @@ export function canStopServer(state: ServerInstanceState): boolean { } export function isPendingJobState(state: JobResponse["state"]): boolean { - return state === "queued" || state === "accepted" || state === "running"; + return state === "queued" || state === "accepted" || state === "running" || state === "retrying"; } export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState { + const plugin = plugins[0]; return { ...emptyServerCreateForm, - pluginId: plugins[0]?.id ?? "", + pluginId: plugin?.id ?? "", + profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", runEndpointId: endpoints[0]?.id ?? "" }; } +export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] { + const profiles = plugin?.runtimeProfiles; + const lifecycle = profiles?.lifecycleProfiles?.find((profile) => profile.key === profileKey); + if (!profiles || !lifecycle) return []; + const fields = new Map(); + const add = (key: string | undefined, required: boolean) => { + if (!key) return; + const current = fields.get(key); + fields.set(key, { key, required: required || current?.required === true, sensitive: runtimeBindingKeyIsSensitive(key) }); + }; + profiles.discovery?.forEach((probe) => add(probe.targetKey, probe.required === true)); + profiles.dependencyProbes?.forEach((probe) => add(probe.targetKey, probe.required === true)); + profiles.logSources?.forEach((source) => add(source.targetKey, Boolean(source.targetKey))); + profiles.installPlans?.forEach((plan) => plan.steps.forEach((step) => add(step.targetKey, false))); + profiles.transportProfiles?.filter((transport) => lifecycle.transportKeys?.includes(transport.key)).forEach((transport) => add(transport.targetKey || transport.key, true)); + add(lifecycle.clientManagerRef, Boolean(lifecycle.clientManagerRef)); + return [...fields.values()].sort((left, right) => left.key.localeCompare(right.key)); +} + +export function runtimeBindingKeyIsSensitive(key: string): boolean { + const normalized = key.toLowerCase(); + return ["password", "credential", "secret", "token", "dsn"].some((part) => normalized.includes(part)); +} + export function serverMetadataFormFromInstance(instance: ServerInstanceResponse): ServerMetadataFormState { return { name: instance.name }; } diff --git a/platform_web/contracts/workspace.ts b/platform_web/contracts/workspace.ts index 60503c9..0e65840 100644 --- a/platform_web/contracts/workspace.ts +++ b/platform_web/contracts/workspace.ts @@ -156,7 +156,7 @@ export interface PluginControlDescriptor { label: string; description: string; capability: string; - lifecycleAction?: "start" | "stop"; + lifecycleAction?: "start" | "stop" | "status"; dangerous: boolean; } @@ -175,7 +175,8 @@ export interface DiffLine { export interface ConfigDiffView { serverInstanceId: string; - configVersion?: number; + configVersion?: number; + checksum?: string; key?: string; source?: string; summary: string; diff --git a/platform_web/pages/AiProvidersPage.test.tsx b/platform_web/pages/AiProvidersPage.test.tsx index 60e07f4..110a078 100644 --- a/platform_web/pages/AiProvidersPage.test.tsx +++ b/platform_web/pages/AiProvidersPage.test.tsx @@ -9,7 +9,7 @@ const provider: AiProviderResponse = { name: "OpenAI Relay", kind: "openai-compatible", baseUrl: "https://relay.example.test/v1", - apiKeyRef: "secret://providers/openai", + apiKeyConfigured: true, models: ["gpt-4.1", "gpt-4.1-mini"], defaultModel: "gpt-4.1-mini", relayMode: "relay", @@ -43,7 +43,8 @@ describe("AiProvidersPage", () => { expect(html).toContain("OpenAI Relay"); expect(html).toContain("本地开发"); - expect(html).toContain("secret://providers/openai"); + expect(html).toContain("已配置"); + expect(html).not.toContain("secret://providers/openai"); expect(html).toContain("测试"); expect(html).toContain("模型"); expect(html).toContain("编辑"); diff --git a/platform_web/pages/AiProvidersPage.tsx b/platform_web/pages/AiProvidersPage.tsx index ead7f7f..662da18 100644 --- a/platform_web/pages/AiProvidersPage.tsx +++ b/platform_web/pages/AiProvidersPage.tsx @@ -118,6 +118,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) { const key = event.target.name as keyof AiProviderFormState; if (key === "kind") { setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind)); + } else if (key === "apiKeyRef") { + setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) })); } else { updateForm(key, event.target.value); } @@ -166,7 +168,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) { if (!completed.baseUrl.trim()) { missing.push("Base URL"); } - if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) { + if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) { missing.push("secret://providers/... 密钥引用"); } if (models.length === 0) { @@ -365,7 +367,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) { 类型 模式 模型 - 密钥引用 + 密钥状态 操作 @@ -385,7 +387,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) { {provider.relayMode} {provider.models.length} - {provider.apiKeyRef} + {provider.apiKeyConfigured ? "已配置" : "未配置"}
@@ -459,7 +461,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
@@ -360,6 +363,8 @@ function jobStateLabel(state: JobResponse["state"]): string { return "已接收"; case "running": return "运行中"; + case "retrying": + return "等待重试"; case "succeeded": return "成功"; case "cancelled": diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index f659b7e..851df90 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -90,12 +90,24 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("sqlite://"); }); + it("reviews and updates only redacted runtime binding metadata", () => { + const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? ""; + expect(serverDetailPageSource).toContain("getServerRuntimeBinding"); + expect(serverDetailPageSource).toContain("updateServerRuntimeBinding"); + expect(runtimeBindingSectionSource).toContain("missingKeys"); + expect(runtimeBindingSectionSource).toContain('type={field.sensitive ? "password" : "text"}'); + for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://", "mysql://", "sqlite://"]) { + expect(runtimeBindingSectionSource).not.toContain(forbidden); + } + }); + it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => { - expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"'); - expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"'); - expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop"'); + expect(serverDetailPageSource).toContain('action === "install" || action === "restart"'); + expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop" && action !== "status"'); + expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop" || control.lifecycleAction === "status"'); expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(instance.id"); expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(instance.id"); + expect(serverDetailPageSource).toContain("platformApiClient.queryServerProcessStatus(instance.id"); expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"start\")"); expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"stop\")"); expect(serverDetailPageSource).not.toContain('capability: "process.start"'); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 4c558cb..282b3ab 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -6,18 +6,24 @@ import type { ConfigDiffLineResponse, ArtifactDownloadReferenceResponse, ArtifactResponse, + BackupResponse, ClientManagerDistributionResponse, + DependencyCatalogResponse, GamePluginResponse, JobResponse, LogEntryBody, LogStreamResponse, RunDistributionResponse, + RunUpdateJobResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse, ServerInstanceResponse, ServerMemberResponse, ServerMetricsResponse, - ServerRuntimeActionsResponse + RuntimeBindingResponse, + ServerRuntimeActionsResponse, + MetricSampleResponse, + RemoteAdapterDeclarationResponse } from "../api/types"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { @@ -34,10 +40,11 @@ import { import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; -import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { canArchiveServer, canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { serverDetailSections, serverIsOnline, + isPlatformAdmin, type ConfigDiffView, type LlmSuggestionView, type PluginControlDescriptor, @@ -72,7 +79,11 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa const [plugins, setPlugins] = useState([]); const [jobs, setJobs] = useState([]); const [artifacts, setArtifacts] = useState([]); + const [metricHistory, setMetricHistory] = useState([]); + const [backups, setBackups] = useState([]); + const [remoteAdapters, setRemoteAdapters] = useState([]); const [runtimeActions, setRuntimeActions] = useState>({ status: "loading" }); + const [runtimeBinding, setRuntimeBinding] = useState>({ status: "loading" }); const [confirm, setConfirm] = useState Promise }>(null); const [confirmBusy, setConfirmBusy] = useState(false); @@ -83,19 +94,30 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa } setInstance({ status: "loading" }); try { - const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([ + const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listGamePlugins(), platformApiClient.listJobs(serverId), platformApiClient .getServerRuntimeActions(serverId) .then((data): LoadState => ({ status: "ready", data })) - .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" })), + platformApiClient + .getServerRuntimeBinding(serverId) + .then((data): LoadState => ({ status: "ready", data })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })), + platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), + platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), + platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) ]); setInstance({ status: "ready", data: detail }); setPlugins(pluginResponse.items); setJobs(jobResponse.items); setRuntimeActions(runtimeResponse); + setRuntimeBinding(bindingResponse); + setMetricHistory(metricHistoryResponse.items); + setBackups(backupResponse.items); + setRemoteAdapters(adapterResponse.items); const artifactLists = await Promise.all( jobResponse.items.slice(0, 20).map((job) => platformApiClient @@ -109,6 +131,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); setArtifacts([]); setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" }); + setRuntimeBinding({ status: "error", reason: "运行配置加载失败" }); + setMetricHistory([]); + setBackups([]); + setRemoteAdapters([]); } try { const metricsResponse = await platformApiClient.listServerMetrics(); @@ -211,7 +237,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa + + + )} + + ); +} + function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) { const defaults = runtimeDefaultsForPlugin(instance.pluginId); const [targetOs, setTargetOs] = useState(defaults.runOs); @@ -617,9 +772,44 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati const [lastClient, setLastClient] = useState(null); const [lastDownload, setLastDownload] = useState(null); const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); + const [dependencyCatalog, setDependencyCatalog] = useState>({ status: "loading" }); + const [runUpdates, setRunUpdates] = useState>({ status: "loading" }); const runtimeTask = useRuntimeTaskController(); const [runtimeTaskActions, setRuntimeTaskActions] = useState([]); + const refreshRuntimeProjections = useCallback(async () => { + const [catalog, updates] = await Promise.all([ + platformApiClient + .getDependencyCatalog(instance.id) + .then((data): LoadState => ({ status: "ready", data })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })), + platformApiClient + .listRunUpdates(instance.id) + .then((data): LoadState => ({ status: "ready", data: data.items })) + .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "Run 更新状态加载失败" })) + ]); + setDependencyCatalog(catalog); + setRunUpdates(updates); + }, [instance.id]); + + useEffect(() => { + void refreshRuntimeProjections(); + }, [refreshRuntimeProjections]); + + useEffect(() => { + if (dependencyCatalog.status !== "ready") return; + const selectedProbe = dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) ?? dependencyCatalog.data.probes[0]; + if (selectedProbe && selectedProbe.key !== probeKey) setProbeKey(selectedProbe.key); + const matchingPlan = dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) + ?? dependencyCatalog.data.plans.find((plan) => plan.key === selectedProbe?.installPlanKey) + ?? dependencyCatalog.data.plans[0]; + if (matchingPlan && matchingPlan.key !== installPlanKey) setInstallPlanKey(matchingPlan.key); + }, [dependencyCatalog, installPlanKey, probeKey]); + + const selectedDependencyProbe = dependencyCatalog.status === "ready" ? dependencyCatalog.data.probes.find((probe) => probe.key === probeKey) : undefined; + const selectedDependencyPlan = dependencyCatalog.status === "ready" ? dependencyCatalog.data.plans.find((plan) => plan.key === installPlanKey) : undefined; + const latestRunUpdate = runUpdates.status === "ready" ? runUpdates.data[0] : undefined; + const actionByKey = useMemo(() => { if (runtimeActions.status !== "ready") { return new Map(); @@ -676,6 +866,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati setResult({ status: "succeeded", label }); runtimeTask.succeedTask(label); taskOptions?.afterSuccess?.(value); + void refreshRuntimeProjections(); onChanged(); } catch (error) { const reason = error instanceof Error ? error.message : `${intent} 失败`; @@ -802,11 +993,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
)} +
+
+

持久化观测

+
+
+
+
指标样本{metricHistory.length} 条
+
最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}
+
+
+
备份记录{backups.length} 条
+
{backups.slice(0, 4).map((backup) => {backup.id} · {backup.state} · {backup.checksum.slice(0, 18)})}
+
+
+
远端适配器声明{remoteAdapters.length} 个
+
{remoteAdapters.slice(0, 4).map((adapter) => {adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")})}
+
+
+
); } diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 250e421..652c34e 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -22,6 +22,7 @@ import { endpointLabel, pendingJobsForServer, pluginLabel, + runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement"; import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace"; @@ -75,13 +76,21 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr setEndpoints(endpointResponse.items); setInstances(instanceResponse.items); setJobs(jobResponse.items); - setForm((current) => ({ - ...current, - pluginId: pluginResponse.items.some((plugin) => plugin.id === current.pluginId) ? current.pluginId : pluginResponse.items[0]?.id || "", - runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) - ? current.runEndpointId - : endpointResponse.items[0]?.id || "" - })); + setForm((current) => { + const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0]; + const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey) + ? current.profileKey + : plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? ""; + return { + ...current, + pluginId: plugin?.id ?? "", + profileKey, + bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {}, + runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId) + ? current.runEndpointId + : endpointResponse.items[0]?.id || "" + }; + }); setListState("ready"); setListError(""); } catch (error) { @@ -115,10 +124,25 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]); const createPending = operations.isPending("platform", "创建服务器"); + const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId); + const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey); function updateForm(event: ChangeEvent) { const { name, value } = event.target; - setForm((current) => ({ ...current, [name]: value })); + setForm((current) => { + if (name === "pluginId") { + const plugin = plugins.find((item) => item.id === value); + return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} }; + } + if (name === "profileKey") { + return { ...current, profileKey: value, bindings: {} }; + } + return { ...current, [name]: value }; + }); + } + + function updateBinding(key: string, value: string) { + setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); } async function handleCreate(event: FormEvent) { @@ -203,7 +227,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr return `依赖检查任务已排队,job ${job.id}`; } if (action === "dependencies-install") { - const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey)); + const catalog = await platformApiClient.getDependencyCatalog(instance.id); + const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey); + const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey); + if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划"); + const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest)); return `依赖安装任务已排队,job ${job.id}`; } if (action === "live-logs") { @@ -384,8 +412,31 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr ))} + + {createBindingFields.map((field) => ( + + ))} - diff --git a/platform_web/schemas/aiProviders.test.ts b/platform_web/schemas/aiProviders.test.ts index d9f44e1..c991b1a 100644 --- a/platform_web/schemas/aiProviders.test.ts +++ b/platform_web/schemas/aiProviders.test.ts @@ -32,4 +32,16 @@ describe("ai provider form schemas", () => { }); expect(request.models).toEqual(["gpt-oss:20b"]); }); + + it("keeps an existing configured secret opaque during edits", () => { + const request = aiProviderUpdateRequestFromForm({ + ...emptyAiProviderForm(), + id: "ai.openai", + apiKeyRef: "", + apiKeyConfigured: true + }); + + expect(request.apiKeyRef).toBe(""); + expect(JSON.stringify(request)).not.toContain("secret://providers/openai"); + }); }); diff --git a/platform_web/schemas/jobs.test.ts b/platform_web/schemas/jobs.test.ts new file mode 100644 index 0000000..f6083c1 --- /dev/null +++ b/platform_web/schemas/jobs.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { parseSafeJobResponse } from "./jobs"; + +const retryingJob = { + id: "job-1", + runEndpointId: "run-local", + capability: "process.start", + idempotencyKey: "idem-1", + state: "retrying", + progress: { percent: 10, message: "Run acknowledgement deadline expired" }, + retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 }, + attempt: 1, + nextAttemptAt: "2026-07-18T12:00:02Z", + reconcileCount: 1, + reconcileOutcome: "missing from Run journal", + createdAt: "2026-07-18T12:00:00Z", + updatedAt: "2026-07-18T12:00:00Z" +}; + +describe("safe job projection schema", () => { + it("accepts retry and reconciliation metadata", () => { + expect(parseSafeJobResponse(retryingJob)).toMatchObject({ state: "retrying", attempt: 1, retryPolicy: { maxAttempts: 3 }, reconcileCount: 1 }); + }); + + it.each(["leaseToken", "leaseTokenHash", "sessionToken", "secretRef", "hostPath", "socket"])("rejects forbidden %s fields", (field) => { + expect(() => parseSafeJobResponse({ ...retryingJob, [field]: "forbidden" })).toThrow(/forbidden field/); + }); + + it("accepts safe typed execution metadata without private content", () => { + const parsed = parseSafeJobResponse({ + ...retryingJob, + state: "succeeded", + executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, auditSummary: "atomic compare-and-swap file write" } + }); + expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 }); + expect(parsed.executionResult).not.toHaveProperty("content"); + expect(() => parseSafeJobResponse({ ...retryingJob, executionResult: { content: "private" } })).toThrow(/forbidden field/); + }); +}); diff --git a/platform_web/schemas/jobs.ts b/platform_web/schemas/jobs.ts new file mode 100644 index 0000000..1919b25 --- /dev/null +++ b/platform_web/schemas/jobs.ts @@ -0,0 +1,112 @@ +import type { JobResponse, JobState } from "../api/types"; + +const jobStates = new Set(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]); +const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential", "content"]); + +export function parseSafeJobResponse(value: unknown): JobResponse { + if (!isRecord(value)) throw new Error("job projection must be an object"); + rejectForbiddenKeys(value); + const state = requiredString(value, "state") as JobState; + if (!jobStates.has(state)) throw new Error("job state is invalid"); + const progress = requiredRecord(value, "progress"); + const retryPolicy = requiredRecord(value, "retryPolicy"); + const parsed: JobResponse = { + id: requiredString(value, "id"), + serverInstanceId: optionalString(value, "serverInstanceId"), + runEndpointId: requiredString(value, "runEndpointId"), + capability: requiredString(value, "capability"), + targetKey: optionalString(value, "targetKey"), + inputRef: optionalString(value, "inputRef"), + idempotencyKey: requiredString(value, "idempotencyKey"), + state, + progress: { percent: requiredNumber(progress, "percent"), message: optionalString(progress, "message") }, + resultRef: optionalString(value, "resultRef"), + executionResult: optionalExecutionResult(value, "executionResult"), + retryPolicy: { + maxAttempts: requiredNumber(retryPolicy, "maxAttempts"), + initialBackoffSeconds: requiredNumber(retryPolicy, "initialBackoffSeconds"), + maxBackoffSeconds: requiredNumber(retryPolicy, "maxBackoffSeconds") + }, + attempt: requiredNumber(value, "attempt"), + nextAttemptAt: optionalString(value, "nextAttemptAt"), + ackDeadlineAt: optionalString(value, "ackDeadlineAt"), + leaseExpiresAt: optionalString(value, "leaseExpiresAt"), + cancelReason: optionalString(value, "cancelReason"), + cancelRequestedAt: optionalString(value, "cancelRequestedAt"), + cancelCompletedAt: optionalString(value, "cancelCompletedAt"), + terminalAt: optionalString(value, "terminalAt"), + lastReconciledAt: optionalString(value, "lastReconciledAt"), + reconcileCount: requiredNumber(value, "reconcileCount"), + reconcileOutcome: optionalString(value, "reconcileOutcome"), + createdAt: requiredString(value, "createdAt"), + updatedAt: requiredString(value, "updatedAt") + }; + return parsed; +} + +function optionalExecutionResult(value: Record, key: string): JobResponse["executionResult"] { + const field = value[key]; + if (field === undefined) return undefined; + if (!isRecord(field)) throw new Error(`${key} must be an object`); + rejectForbiddenKeys(field); + const result: NonNullable = { + kind: optionalString(field, "kind"), + processState: optionalString(field, "processState"), + exitClassification: optionalString(field, "exitClassification"), + exitCode: optionalSignedNumber(field, "exitCode"), + version: optionalNumber(field, "version"), + checksum: optionalString(field, "checksum"), + sizeBytes: optionalNumber(field, "sizeBytes"), + auditSummary: optionalString(field, "auditSummary") + }; + return result; +} + +function rejectForbiddenKeys(value: Record): void { + for (const key of Object.keys(value)) { + if (forbiddenProjectionKeys.has(key)) throw new Error(`job projection contains forbidden field ${key}`); + } +} + +function requiredRecord(value: Record, key: string): Record { + const field = value[key]; + if (!isRecord(field)) throw new Error(`${key} must be an object`); + rejectForbiddenKeys(field); + return field; +} + +function requiredString(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`); + return field; +} + +function optionalString(value: Record, key: string): string | undefined { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== "string") throw new Error(`${key} must be a string`); + return field; +} + +function optionalSignedNumber(value: Record, key: string): number | undefined { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== "number" || !Number.isFinite(field)) throw new Error(`${key} must be a number`); + return field; +} + +function optionalNumber(value: Record, key: string): number | undefined { + const field = optionalSignedNumber(value, key); + if (field !== undefined && field < 0) throw new Error(`${key} must be non-negative`); + return field; +} + +function requiredNumber(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`); + return field; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/platform_web/schemas/runtimeUpdates.test.ts b/platform_web/schemas/runtimeUpdates.test.ts new file mode 100644 index 0000000..48741b9 --- /dev/null +++ b/platform_web/schemas/runtimeUpdates.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { parseSafeDependencyCatalog, parseSafeRunUpdateList } from "./runtimeUpdates"; + +const digest = `sha256:${"a".repeat(64)}`; + +describe("safe dependency and Run update projections", () => { + it("accepts reviewable plans, safe evidence, checksums, and rollback phases", () => { + const catalog = parseSafeDependencyCatalog({ + serverInstanceId: "server-1", + pluginId: "game.runtime", + pluginVersion: "1.0.0", + profileKey: "local", + targetOs: "linux", + targetArch: "amd64", + probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "OpenJDK 21", installPlanKey: "java-install" }], + plans: [{ key: "java-install", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }], + updatedAt: "2026-07-18T12:00:00Z" + }); + expect(catalog.plans[0]).toMatchObject({ digest, targetOs: "linux" }); + + const updates = parseSafeRunUpdateList({ + items: [{ + id: "update-1", + serverInstanceId: "server-1", + runEndpointId: "run-1", + artifactId: "artifact-1", + checksum: digest, + targetOs: "linux", + targetArch: "amd64", + targetRelease: "release-2", + previousVersion: "release-1", + jobId: "job-1", + status: "failed", + phase: "rolled-back", + message: "previous executable restored", + rollback: true, + createdAt: "2026-07-18T12:00:00Z", + updatedAt: "2026-07-18T12:01:00Z" + }], + count: 1 + }); + expect(updates.items[0]).toMatchObject({ phase: "rolled-back", rollback: true, checksum: digest }); + }); + + it.each(["leaseToken", "sessionToken", "secretRef", "hostPath", "socket", "credential", "pid", "payload", "bindings"])("rejects forbidden %s fields recursively", (field) => { + expect(() => parseSafeDependencyCatalog({ + serverInstanceId: "server-1", + pluginId: "game.runtime", + pluginVersion: "1.0.0", + profileKey: "local", + targetOs: "linux", + targetArch: "amd64", + probes: [{ key: "java", kind: "java.version", required: true, state: "unknown", [field]: "private" }], + plans: [], + updatedAt: "2026-07-18T12:00:00Z" + })).toThrow(/forbidden field/); + }); + + it("rejects raw host paths or credentials hidden in safe-looking evidence", () => { + expect(() => parseSafeDependencyCatalog({ + serverInstanceId: "server-1", + pluginId: "game.runtime", + pluginVersion: "1.0.0", + profileKey: "local", + targetOs: "linux", + targetArch: "amd64", + probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "/Users/operator/private" }], + plans: [], + updatedAt: "2026-07-18T12:00:00Z" + })).toThrow(/unsafe runtime details/); + }); +}); diff --git a/platform_web/schemas/runtimeUpdates.ts b/platform_web/schemas/runtimeUpdates.ts new file mode 100644 index 0000000..9838c11 --- /dev/null +++ b/platform_web/schemas/runtimeUpdates.ts @@ -0,0 +1,202 @@ +import type { + DependencyCatalogResponse, + DependencyPlanStepViewResponse, + DependencyPlanViewResponse, + DependencyProbeViewResponse, + DependencyState, + RunUpdateJobListResponse, + RunUpdateJobResponse, + RunUpdatePhase +} from "../api/types"; + +const dependencyStates = new Set(["unknown", "present", "missing", "installing", "failed"]); +const updatePhases = new Set(["queued", "downloading", "staged", "restart-requested", "activating", "succeeded", "rolled-back", "failed"]); +const updateStatuses = new Set(["queued", "running", "succeeded", "failed", "denied"]); +const forbiddenProjectionKeys = new Set([ + "leasetoken", + "leasetokenhash", + "leasesessiongeneration", + "sessiontoken", + "runtoken", + "secretref", + "hostpath", + "executablepath", + "stagingpath", + "backuppath", + "socket", + "credential", + "pid", + "content", + "payload", + "bindings", + "downloadref" +]); +const unsafeProjectionText = /(?:\/Users\/|\/home\/|\/var\/run\/|[A-Za-z]:\\|unix:\/\/|tcp:\/\/|Bearer\s+|password=|token=|sk-[A-Za-z0-9_-]+)/i; + +export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse { + const record = requiredRecordValue(value, "dependency catalog"); + rejectForbiddenProjection(record); + return { + serverInstanceId: requiredString(record, "serverInstanceId"), + pluginId: requiredString(record, "pluginId"), + pluginVersion: requiredString(record, "pluginVersion"), + profileKey: requiredString(record, "profileKey"), + targetOs: requiredString(record, "targetOs"), + targetArch: requiredString(record, "targetArch"), + probes: requiredArray(record, "probes").map(parseDependencyProbe), + plans: requiredArray(record, "plans").map(parseDependencyPlan), + updatedAt: requiredString(record, "updatedAt") + }; +} + +export function parseSafeRunUpdateList(value: unknown): RunUpdateJobListResponse { + const record = requiredRecordValue(value, "Run update list"); + rejectForbiddenProjection(record); + const items = requiredArray(record, "items").map((item) => parseRunUpdate(requiredRecordValue(item, "Run update"))); + const count = requiredNumber(record, "count"); + if (count !== items.length) throw new Error("Run update count does not match items"); + return { items, count }; +} + +export function parseSafeRunUpdate(value: unknown): RunUpdateJobResponse { + const record = requiredRecordValue(value, "Run update"); + rejectForbiddenProjection(record); + return parseRunUpdate(record); +} + +function parseDependencyProbe(value: unknown): DependencyProbeViewResponse { + const record = requiredRecordValue(value, "dependency probe"); + const state = requiredString(record, "state") as DependencyState; + if (!dependencyStates.has(state)) throw new Error("dependency state is invalid"); + return { + key: requiredString(record, "key"), + kind: requiredString(record, "kind"), + required: requiredBoolean(record, "required"), + minimumVersion: optionalSafeString(record, "minimumVersion"), + state, + evidence: optionalSafeString(record, "evidence"), + installPlanKey: optionalString(record, "installPlanKey") + }; +} + +function parseDependencyPlan(value: unknown): DependencyPlanViewResponse { + const record = requiredRecordValue(value, "dependency plan"); + return { + key: requiredString(record, "key"), + title: requiredString(record, "title"), + targetOs: requiredString(record, "targetOs"), + targetArch: requiredString(record, "targetArch"), + digest: requiredChecksum(record, "digest"), + steps: requiredArray(record, "steps").map(parseDependencyStep) + }; +} + +function parseDependencyStep(value: unknown): DependencyPlanStepViewResponse { + const record = requiredRecordValue(value, "dependency plan step"); + const downloadHost = optionalString(record, "downloadHost"); + if (downloadHost && (downloadHost.includes("/") || downloadHost.includes("@") || downloadHost.includes(":"))) throw new Error("dependency download host is invalid"); + return { + type: requiredString(record, "type"), + targetKey: requiredString(record, "targetKey"), + packageManager: optionalString(record, "packageManager"), + packageName: optionalString(record, "packageName"), + version: optionalString(record, "version"), + downloadHost, + sizeBytes: optionalNumber(record, "sizeBytes") + }; +} + +function parseRunUpdate(record: Record): RunUpdateJobResponse { + const phase = requiredString(record, "phase") as RunUpdatePhase; + if (!updatePhases.has(phase)) throw new Error("Run update phase is invalid"); + const status = requiredString(record, "status"); + if (!updateStatuses.has(status)) throw new Error("Run update status is invalid"); + return { + id: requiredString(record, "id"), + serverInstanceId: requiredString(record, "serverInstanceId"), + runEndpointId: requiredString(record, "runEndpointId"), + artifactId: requiredString(record, "artifactId"), + checksum: requiredChecksum(record, "checksum"), + targetOs: requiredString(record, "targetOs"), + targetArch: requiredString(record, "targetArch"), + targetRelease: optionalString(record, "targetRelease"), + previousVersion: optionalString(record, "previousVersion"), + jobId: optionalString(record, "jobId"), + idempotencyKey: optionalString(record, "idempotencyKey"), + status, + phase, + message: optionalSafeString(record, "message"), + rollback: requiredBoolean(record, "rollback"), + createdAt: requiredString(record, "createdAt"), + updatedAt: requiredString(record, "updatedAt") + }; +} + +function rejectForbiddenProjection(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(rejectForbiddenProjection); + return; + } + if (!isRecord(value)) return; + for (const [key, field] of Object.entries(value)) { + if (forbiddenProjectionKeys.has(key.toLowerCase())) throw new Error(`runtime projection contains forbidden field ${key}`); + rejectForbiddenProjection(field); + } +} + +function requiredRecordValue(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`${label} must be an object`); + return value; +} + +function requiredArray(value: Record, key: string): unknown[] { + const field = value[key]; + if (!Array.isArray(field)) throw new Error(`${key} must be an array`); + return field; +} + +function requiredString(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`); + return field; +} + +function optionalString(value: Record, key: string): string | undefined { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== "string") throw new Error(`${key} must be a string`); + return field; +} + +function optionalSafeString(value: Record, key: string): string | undefined { + const field = optionalString(value, key); + if (field && unsafeProjectionText.test(field)) throw new Error(`${key} contains unsafe runtime details`); + return field; +} + +function requiredChecksum(value: Record, key: string): string { + const field = requiredString(value, key); + if (!/^sha256:[a-f0-9]{64}$/.test(field)) throw new Error(`${key} must be a SHA-256 checksum`); + return field; +} + +function requiredNumber(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`); + return field; +} + +function optionalNumber(value: Record, key: string): number | undefined { + if (value[key] === undefined) return undefined; + return requiredNumber(value, key); +} + +function requiredBoolean(value: Record, key: string): boolean { + const field = value[key]; + if (typeof field !== "boolean") throw new Error(`${key} must be a boolean`); + return field; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/platform_web/schemas/serverManagement.test.ts b/platform_web/schemas/serverManagement.test.ts new file mode 100644 index 0000000..b9b61f1 --- /dev/null +++ b/platform_web/schemas/serverManagement.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import type { GamePluginResponse } from "../api/types"; +import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement"; +import { serverCreateRequestFromForm } from "./serverManagement"; + +const plugin: GamePluginResponse = { + id: "game.runtime", + name: "Runtime Game", + version: "1.0.0", + serverType: "runtime", + manifestRef: "artifact://runtime-manifest", + createFormSchemaRef: "schemas/create.json", + requiredRunCapabilities: ["process.install"], + declaredPermissions: ["server.create"], + permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false }, + lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" }, + bridgeActions: [], + pages: [], + tags: [], + aiPurposes: [], + status: "installed", + runtimeProfiles: { + discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }], + dependencyProbes: [{ key: "java", kind: "java.version", targetKey: "java-runtime", required: false }], + installPlans: [{ key: "java-install", title: "Java", steps: [{ type: "package", targetKey: "package-source" }] }], + logSources: [{ key: "main-log", kind: "file.tail", targetKey: "log-source", streamKey: "main" }], + transportProfiles: [ + { key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] }, + { key: "ftp", kind: "ftp", targetKey: "ftp.profile", capabilities: ["remote.ftp.read"] } + ], + lifecycleProfiles: [ + { key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"], transportKeys: ["rcon"] }, + { key: "hosted", mode: "hosted-ftp-rcon", capabilities: ["remote.ftp.read"], transportKeys: ["ftp"] } + ] + } +}; + +describe("runtime profile server creation contracts", () => { + it("derives logical binding fields from the selected profile", () => { + expect(runtimeBindingFields(plugin, "local")).toEqual([ + { key: "java-runtime", required: false, sensitive: false }, + { key: "log-source", required: true, sensitive: false }, + { key: "package-source", required: false, sensitive: false }, + { key: "rcon.password", required: true, sensitive: true }, + { key: "server-root", required: true, sensitive: false } + ]); + expect(runtimeBindingFields(plugin, "local").some((field) => field.key === "ftp.profile")).toBe(false); + }); + + it("selects the plugin profile and submits real profile bindings", () => { + const form = defaultServerCreateForm([plugin], []); + expect(form.profileKey).toBe("local"); + expect( + serverCreateRequestFromForm( + { + ...form, + id: " server-1 ", + name: " Runtime Server ", + bindings: { "server-root": " runtime.server-root ", "rcon.password": " secret://runtime/server-1/rcon ", "java-runtime": " " } + }, + 17 + ) + ).toEqual({ + id: "server-1", + pluginId: "game.runtime", + runEndpointId: "", + name: "Runtime Server", + idempotencyKey: "web:create:server-1:17", + profileKey: "local", + bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" } + }); + }); +}); diff --git a/platform_web/schemas/serverManagement.ts b/platform_web/schemas/serverManagement.ts index 9785084..1f09807 100644 --- a/platform_web/schemas/serverManagement.ts +++ b/platform_web/schemas/serverManagement.ts @@ -18,13 +18,16 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc pluginId: form.pluginId.trim(), runEndpointId: form.runEndpointId.trim(), name: form.name.trim(), - idempotencyKey: lifecycleIdempotencyKey("create", id, sequence) + idempotencyKey: lifecycleIdempotencyKey("create", id, sequence), + profileKey: form.profileKey.trim(), + bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")) }; } -export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop", sequence = Date.now()): ServerLifecycleCommandRequest { - return { - expectedConfigVersion: instance.configVersion, +export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest { + return { + expectedConfigVersion: instance.configVersion, + expectedChecksum: instance.configChecksum, idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence) }; } @@ -77,10 +80,11 @@ export function clientManagerBuildRequest(input: { }; } -export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", sequence = Date.now()): DependencyJobRequest { +export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", planDigest = "", sequence = Date.now()): DependencyJobRequest { return { probeKey: probeKey.trim(), installPlanKey: installPlanKey.trim() || undefined, + planDigest: planDigest.trim() || undefined, idempotencyKey: runtimeIdempotencyKey(installPlanKey ? "dependencies.install" : "dependencies.check", serverInstanceId, sequence) }; } @@ -98,6 +102,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string, return `web:${action}:${serverInstanceId}:${sequence}`; } -export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string { +export function lifecycleIdempotencyKey(action: "create" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string { return `web:${action}:${serverInstanceId}:${sequence}`; } diff --git a/platform_web/stores/session.test.ts b/platform_web/stores/session.test.ts index f67ce80..8bff6a8 100644 --- a/platform_web/stores/session.test.ts +++ b/platform_web/stores/session.test.ts @@ -25,9 +25,24 @@ describe("session store helpers", () => { it("returns null without a stored API session token", async () => { stubWindowStorage(new Map()); + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "unauthorized" }, 401))); await expect(loadCurrentUser()).resolves.toBeNull(); }); + it("restores an HttpOnly cookie session without a script-readable token", async () => { + const storage = new Map(); + stubWindowStorage(storage); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).has("Authorization")).toBe(false); + expect(init?.credentials).toBe("same-origin"); + return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(loadCurrentUser()).resolves.toMatchObject({ id: "user-admin", source: "api" }); + expect(storage.size).toBe(0); + }); + it("loads current user with a stored API bearer token", async () => { const storage = new Map([["platform-web.session.apiToken", "session-token"]]); stubWindowStorage(storage); diff --git a/platform_web/stores/session.ts b/platform_web/stores/session.ts index 50ebf24..a961dc6 100644 --- a/platform_web/stores/session.ts +++ b/platform_web/stores/session.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { platformApiClient, setPlatformApiSessionToken } from "../api/client"; +import { platformApiClient, setPlatformApiAuthFailureHandler, setPlatformApiSessionToken } from "../api/client"; import type { AuthSessionResponse, CurrentUserResponse, @@ -59,9 +59,6 @@ export interface SessionState { export async function loadCurrentUser(): Promise { const storedToken = readStoredSessionToken(); setPlatformApiSessionToken(storedToken); - if (!storedToken) { - return null; - } try { const response = await platformApiClient.getCurrentUser(); return currentUserFromResponse(response, "api"); @@ -93,6 +90,16 @@ export function useSession(): SessionState { }; }, []); + useEffect(() => { + setPlatformApiAuthFailureHandler(() => { + persistSessionToken(null); + setUser(undefined); + setAuthUnavailable(false); + setAuth({ mode: "login", pending: false, error: "会话已失效,请重新登录。" }); + }); + return () => setPlatformApiAuthFailureHandler(null); + }, []); + async function login(request: LoginRequest) { setAuth({ mode: "login", pending: true }); try { diff --git a/plugins/README.md b/plugins/README.md index a2a4999..c48553c 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -40,18 +40,21 @@ Runtime profiles are declarative contracts, not executable scripts. A profile ca - transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations. - client-manager build profiles for games such as SCUM that need a separate companion executable. -Client-manager profiles declare repository URL, revision policy, supported target OS/architecture pairs, build system hints, config template keys, dependency hints, and produced artifact paths. Platform performs target validation, creates a build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. The run key and client-manager key are separate singleton keys in platform storage; resetting either key revokes packages from older generations and requires regenerating that component. +Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values. + +Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a controlled workspace, registered using a separate short-lived component session, health-checked, controlled, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy. Plugin pages may request these operations only through bridge helpers: - `createRunDistributionRequest`: generate/download/reset/update run packages. - `createDependencyActionRequest`: check or install declared dependency probes/plans. - `createLogBackfillRequest`: request historical log cursors for declared sources. -- `createClientManagerRequest`: generate/download/reset declared client-manager packages. +- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages. +- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details. -Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, and idempotency keys only. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets. +Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets. -Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform stores registry metadata only and repeats safety validation before a plugin becomes installable. +Validated manifests are registered through the platform registry API rather than by plugin code importing platform internals. Platform persists the validated runtime-profile declaration with the installed plugin contract and repeats safety validation before a plugin becomes installable. Per-server values are stored separately as platform-owned runtime bindings; plugin pages receive only logical readiness and never the stored values. ## Development Baseline @@ -70,4 +73,6 @@ npm run test npm run validate:manifest ``` -Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, run distribution envelopes, typed dependency/log backfill requests, and SCUM-style client-manager build declarations. Marketplace package acquisition, private source credentials, public build-worker sandboxing, real FTP/rsync/database/RCON adapters beyond bounded envelopes, remote plugin hosting policies, and external package distribution remain future OpenSpec work. +Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and a SCUM-style client-manager declaration with a complete bounded lifecycle contract. Minecraft deliberately remains a no-client-manager example so action gating proves the feature is optional. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work. + +Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections. diff --git a/plugins/examples/dev-game-plugin/actions/install.json b/plugins/examples/dev-game-plugin/actions/install.json new file mode 100644 index 0000000..0d6d00f --- /dev/null +++ b/plugins/examples/dev-game-plugin/actions/install.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "action": "install", + "mode": "oneshot", + "executableKey": "bin/install-server", + "arguments": [], + "environment": { "GAME_ID": "dev" }, + "timeoutMs": 30000 +} diff --git a/plugins/examples/dev-game-plugin/actions/restart.json b/plugins/examples/dev-game-plugin/actions/restart.json new file mode 100644 index 0000000..627e4cf --- /dev/null +++ b/plugins/examples/dev-game-plugin/actions/restart.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "action": "restart", + "mode": "control", + "environment": { "GAME_ID": "dev" }, + "timeoutMs": 30000 +} diff --git a/plugins/examples/dev-game-plugin/actions/start.json b/plugins/examples/dev-game-plugin/actions/start.json new file mode 100644 index 0000000..7eda231 --- /dev/null +++ b/plugins/examples/dev-game-plugin/actions/start.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "action": "start", + "mode": "supervised", + "executableKey": "bin/game-server", + "arguments": ["--foreground"], + "environment": { "GAME_ID": "dev" }, + "timeoutMs": 30000 +} diff --git a/plugins/examples/dev-game-plugin/actions/status.json b/plugins/examples/dev-game-plugin/actions/status.json new file mode 100644 index 0000000..ef247f2 --- /dev/null +++ b/plugins/examples/dev-game-plugin/actions/status.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "action": "status", + "mode": "control", + "environment": { "GAME_ID": "dev" }, + "timeoutMs": 30000 +} diff --git a/plugins/examples/dev-game-plugin/actions/stop.json b/plugins/examples/dev-game-plugin/actions/stop.json new file mode 100644 index 0000000..8401194 --- /dev/null +++ b/plugins/examples/dev-game-plugin/actions/stop.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "action": "stop", + "mode": "control", + "environment": { "GAME_ID": "dev" }, + "stopTimeoutMs": 30000 +} diff --git a/plugins/examples/minecraft-server-plugin/actions/install.json b/plugins/examples/minecraft-server-plugin/actions/install.json index 5d50c34..a272247 100644 --- a/plugins/examples/minecraft-server-plugin/actions/install.json +++ b/plugins/examples/minecraft-server-plugin/actions/install.json @@ -1,6 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "install", + "mode": "oneshot", + "executableKey": "bin/install-server", + "arguments": [], + "environment": { "GAME_ID": "minecraft", "SERVER_TEMPLATE": "minecraft-java" }, diff --git a/plugins/examples/minecraft-server-plugin/actions/restart.json b/plugins/examples/minecraft-server-plugin/actions/restart.json index 2794c09..5e8429a 100644 --- a/plugins/examples/minecraft-server-plugin/actions/restart.json +++ b/plugins/examples/minecraft-server-plugin/actions/restart.json @@ -1,6 +1,8 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "restart", + "mode": "control", + "environment": { "GAME_ID": "minecraft", "SERVER_ACTION": "restart" }, diff --git a/plugins/examples/minecraft-server-plugin/actions/start.json b/plugins/examples/minecraft-server-plugin/actions/start.json index ad5c28a..83e257a 100644 --- a/plugins/examples/minecraft-server-plugin/actions/start.json +++ b/plugins/examples/minecraft-server-plugin/actions/start.json @@ -1,6 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "start", + "mode": "supervised", + "executableKey": "bin/game-server", + "arguments": ["--foreground"], + "environment": { "GAME_ID": "minecraft", "SERVER_ACTION": "start" }, diff --git a/plugins/examples/minecraft-server-plugin/actions/status.json b/plugins/examples/minecraft-server-plugin/actions/status.json index f74408f..94ad208 100644 --- a/plugins/examples/minecraft-server-plugin/actions/status.json +++ b/plugins/examples/minecraft-server-plugin/actions/status.json @@ -1,6 +1,8 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "status", + "mode": "control", + "environment": { "GAME_ID": "minecraft", "SERVER_ACTION": "status" }, diff --git a/plugins/examples/minecraft-server-plugin/actions/stop.json b/plugins/examples/minecraft-server-plugin/actions/stop.json index 2d300ec..99bd6af 100644 --- a/plugins/examples/minecraft-server-plugin/actions/stop.json +++ b/plugins/examples/minecraft-server-plugin/actions/stop.json @@ -1,8 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "stop", + "mode": "control", + "environment": { "GAME_ID": "minecraft", "SERVER_ACTION": "stop" }, - "timeoutMs": 30000 + "stopTimeoutMs": 30000 } diff --git a/plugins/examples/scum-server-plugin/actions/install.json b/plugins/examples/scum-server-plugin/actions/install.json index 813bad2..994463c 100644 --- a/plugins/examples/scum-server-plugin/actions/install.json +++ b/plugins/examples/scum-server-plugin/actions/install.json @@ -1,6 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "install", + "mode": "oneshot", + "executableKey": "bin/install-server", + "arguments": [], + "environment": { "GAME_ID": "scum", "SERVER_TEMPLATE": "scum-local-proof" }, diff --git a/plugins/examples/scum-server-plugin/actions/restart.json b/plugins/examples/scum-server-plugin/actions/restart.json index 7c70bcb..e52f4a8 100644 --- a/plugins/examples/scum-server-plugin/actions/restart.json +++ b/plugins/examples/scum-server-plugin/actions/restart.json @@ -1,6 +1,8 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "restart", + "mode": "control", + "environment": { "GAME_ID": "scum", "SERVER_ACTION": "restart" }, diff --git a/plugins/examples/scum-server-plugin/actions/start.json b/plugins/examples/scum-server-plugin/actions/start.json index f06fd92..d14173a 100644 --- a/plugins/examples/scum-server-plugin/actions/start.json +++ b/plugins/examples/scum-server-plugin/actions/start.json @@ -1,6 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "start", + "mode": "supervised", + "executableKey": "bin/game-server", + "arguments": ["--foreground"], + "environment": { "GAME_ID": "scum", "SERVER_ACTION": "start" }, diff --git a/plugins/examples/scum-server-plugin/actions/status.json b/plugins/examples/scum-server-plugin/actions/status.json index 0740893..4f774b7 100644 --- a/plugins/examples/scum-server-plugin/actions/status.json +++ b/plugins/examples/scum-server-plugin/actions/status.json @@ -1,6 +1,8 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "status", + "mode": "control", + "environment": { "GAME_ID": "scum", "SERVER_ACTION": "status" }, diff --git a/plugins/examples/scum-server-plugin/actions/stop.json b/plugins/examples/scum-server-plugin/actions/stop.json index bce61ce..ea2b5aa 100644 --- a/plugins/examples/scum-server-plugin/actions/stop.json +++ b/plugins/examples/scum-server-plugin/actions/stop.json @@ -1,8 +1,10 @@ { - "command": ["true"], - "env": { + "version": 1, + "action": "stop", + "mode": "control", + "environment": { "GAME_ID": "scum", "SERVER_ACTION": "stop" }, - "timeoutMs": 30000 + "stopTimeoutMs": 30000 } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index bf6b02f..b71b986 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -42,6 +42,11 @@ "remote.run.db.sqlite.query", "remote.run.logs.transfer", "remote.run.rcon.command", + "client-manager.deploy", + "client-manager.control", + "client-manager.update", + "client-manager.rollback", + "client-manager.uninstall", "artifacts.read", "artifacts.write", "ai.invoke" @@ -386,6 +391,7 @@ { "key": "scum-client-manager", "displayName": "SCUM Client Manager", + "version": "1.0.0", "repository": { "url": "https://github.com/F88888/scum_client.git", "revisionPolicy": "branch", @@ -411,7 +417,42 @@ ], "outputArtifacts": [ "scum_client.exe" - ] + ], + "deployment": { + "mode": "run-supervised", + "executableRef": "scum_client.exe", + "arguments": ["--config", "config.json"], + "autoStart": true, + "requiredRunCapabilities": [ + "client-manager.deploy", + "client-manager.control", + "client-manager.update", + "client-manager.rollback", + "client-manager.uninstall" + ] + }, + "lifecycle": { + "actions": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"], + "startupTimeoutSeconds": 60, + "stopTimeoutSeconds": 30 + }, + "health": { + "mode": "component-heartbeat", + "intervalSeconds": 15, + "degradedAfterSeconds": 45, + "offlineAfterSeconds": 120, + "requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] + }, + "compatibility": { + "minimumVersion": "1.0.0", + "allowDowngrade": false + }, + "updatePolicy": { + "strategy": "manual-staged", + "requireApproval": true, + "healthConfirmationSeconds": 60, + "retainPrevious": true + } } ] } diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 21f3846..e6b85d2 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -182,6 +182,11 @@ "remote.run.db.sqlite.query", "remote.run.logs.transfer", "remote.run.rcon.command", + "client-manager.deploy", + "client-manager.control", + "client-manager.update", + "client-manager.rollback", + "client-manager.uninstall", "artifacts.read", "artifacts.write", "ai.invoke" @@ -307,7 +312,30 @@ "version": { "type": "string", "maxLength": 80 }, "downloadRef": { "type": "string", "pattern": "^https://[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+$", "maxLength": 240 }, "checksum": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" } - } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "package" } }, "required": ["type"] }, + "then": { + "required": ["packageManager", "packageName"], + "properties": { "packageManager": { "enum": ["winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew"] } } + } + }, + { + "if": { "properties": { "type": { "const": "verified-download" } }, "required": ["type"] }, + "then": { "required": ["downloadRef", "checksum"] } + }, + { + "if": { "properties": { "type": { "const": "steamcmd-app" } }, "required": ["type"] }, + "then": { + "required": ["packageName"], + "properties": { + "packageManager": { "const": "steamcmd" }, + "packageName": { "type": "string", "pattern": "^[0-9]{1,12}$" } + } + } + } + ] }, "runtimeInstallPlan": { "type": "object", @@ -317,7 +345,7 @@ "key": { "$ref": "#/$defs/logicalKey" }, "title": { "type": "string", "minLength": 1, "maxLength": 80 }, "platforms": { "type": "array", "items": { "$ref": "#/$defs/runtimePlatform" }, "uniqueItems": true }, - "steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1 } + "steps": { "type": "array", "items": { "$ref": "#/$defs/runtimeInstallStep" }, "minItems": 1, "maxItems": 64 } } }, "runtimeLogSource": { @@ -349,6 +377,24 @@ "pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+$", "maxLength": 160 }, + "clientManagerComponentCapability": { + "enum": [ + "component.register", + "component.heartbeat", + "component.health", + "component.control", + "game-client.bridge", + "logs.stream" + ] + }, + "clientManagerLifecycleAction": { + "enum": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"] + }, + "clientManagerArgument": { + "type": "string", + "pattern": "^[a-zA-Z0-9_./:=@+-]+$", + "maxLength": 120 + }, "runtimeClientManagerProfile": { "type": "object", "required": ["key", "repository", "supportedTargets", "build", "outputArtifacts"], @@ -356,6 +402,7 @@ "properties": { "key": { "$ref": "#/$defs/logicalKey" }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 }, "repository": { "type": "object", "required": ["url", "revisionPolicy"], @@ -393,8 +440,74 @@ }, "uniqueItems": true }, - "outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true } - } + "outputArtifacts": { "type": "array", "items": { "$ref": "#/$defs/relativePathRef" }, "minItems": 1, "uniqueItems": true }, + "deployment": { + "type": "object", + "required": ["mode", "executableRef", "requiredRunCapabilities"], + "additionalProperties": false, + "properties": { + "mode": { "const": "run-supervised" }, + "executableRef": { "$ref": "#/$defs/relativePathRef" }, + "arguments": { "type": "array", "items": { "$ref": "#/$defs/clientManagerArgument" }, "maxItems": 32 }, + "autoStart": { "type": "boolean" }, + "requiredRunCapabilities": { + "type": "array", + "items": { "enum": ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"] }, + "uniqueItems": true, + "minItems": 1 + } + } + }, + "lifecycle": { + "type": "object", + "required": ["actions", "startupTimeoutSeconds", "stopTimeoutSeconds"], + "additionalProperties": false, + "properties": { + "actions": { "type": "array", "items": { "$ref": "#/$defs/clientManagerLifecycleAction" }, "uniqueItems": true, "minItems": 1 }, + "startupTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 300 }, + "stopTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 120 } + } + }, + "health": { + "type": "object", + "required": ["mode", "intervalSeconds", "degradedAfterSeconds", "offlineAfterSeconds", "requiredCapabilities"], + "additionalProperties": false, + "properties": { + "mode": { "enum": ["component-heartbeat", "process"] }, + "intervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 }, + "degradedAfterSeconds": { "type": "integer", "minimum": 10, "maximum": 1800 }, + "offlineAfterSeconds": { "type": "integer", "minimum": 15, "maximum": 3600 }, + "requiredCapabilities": { "type": "array", "items": { "$ref": "#/$defs/clientManagerComponentCapability" }, "uniqueItems": true, "minItems": 1 } + } + }, + "compatibility": { + "type": "object", + "required": ["allowDowngrade"], + "additionalProperties": false, + "properties": { + "minimumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 }, + "maximumVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$", "maxLength": 40 }, + "allowDowngrade": { "type": "boolean" } + } + }, + "updatePolicy": { + "type": "object", + "required": ["strategy", "requireApproval", "healthConfirmationSeconds", "retainPrevious"], + "additionalProperties": false, + "properties": { + "strategy": { "const": "manual-staged" }, + "requireApproval": { "const": true }, + "healthConfirmationSeconds": { "type": "integer", "minimum": 5, "maximum": 600 }, + "retainPrevious": { "const": true } + } + } + }, + "allOf": [ + { + "if": { "required": ["deployment"] }, + "then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] } + } + ] }, "aiPurpose": { "enum": [ diff --git a/plugins/manifests/lifecycle-action.schema.json b/plugins/manifests/lifecycle-action.schema.json new file mode 100644 index 0000000..5d52ed1 --- /dev/null +++ b/plugins/manifests/lifecycle-action.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://browser.local/schemas/lifecycle-action.schema.json", + "title": "PluginLifecycleActionDeclaration", + "type": "object", + "required": ["version", "action", "mode"], + "additionalProperties": false, + "properties": { + "version": { "const": 1 }, + "action": { "enum": ["install", "start", "stop", "restart", "status"] }, + "mode": { "enum": ["oneshot", "supervised", "control"] }, + "executableKey": { "$ref": "game-plugin.manifest.schema.json#/$defs/relativePathRef" }, + "arguments": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 240 }, + "maxItems": 64 + }, + "environment": { + "type": "object", + "propertyNames": { "pattern": "^(GAME|SERVER|RUN)_[A-Z0-9_]{1,59}$" }, + "additionalProperties": { "type": "string", "maxLength": 512 }, + "maxProperties": 32 + }, + "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 300000 }, + "stopTimeoutMs": { "type": "integer", "minimum": 1, "maximum": 60000 } + } +} diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 1f322d0..ef9109f 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -6,6 +6,7 @@ import { Ajv2020, type AnySchema, type ErrorObject } from "ajv/dist/2020.js"; const rootDir = fileURLToPath(new URL("..", import.meta.url)); const manifestSchemaPath = path.join(rootDir, "manifests", "game-plugin.manifest.schema.json"); +const lifecycleActionSchemaPath = path.join(rootDir, "manifests", "lifecycle-action.schema.json"); const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json"); function readJson(filePath: string): unknown { @@ -114,6 +115,179 @@ function isSafeRelativeJsonRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); } +export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] { + const action = readJson(path.resolve(rootDir, actionPath)); + const ajv = new Ajv2020({ allErrors: true }); + ajv.addSchema(readJson(manifestSchemaPath) as AnySchema); + const validateAction = ajv.compile(readJson(lifecycleActionSchemaPath) as AnySchema); + const errors = validateAction(action) ? [] : formatErrors("lifecycleAction", validateAction.errors); + errors.push(...scanUnsafeValues(action, "lifecycleAction")); + if (typeof action !== "object" || action === null) { + return errors; + } + const declaration = action as { action?: string; mode?: string; executableKey?: string }; + if (expectedAction && declaration.action !== expectedAction) { + errors.push(`lifecycleAction.action: expected ${expectedAction}`); + } + if ((declaration.action === "install" || declaration.action === "start") && !declaration.executableKey) { + errors.push("lifecycleAction.executableKey: required for install/start"); + } + if (declaration.action === "start" && declaration.mode !== "supervised") { + errors.push("lifecycleAction.mode: start must be supervised"); + } + if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") { + errors.push(`lifecycleAction.mode: ${declaration.action} must be control`); + } + return errors; +} + +function referencedLifecycleActions(manifest: unknown): Array<{ action: string; ref: string }> { + if (typeof manifest !== "object" || manifest === null) { + return []; + } + const record = manifest as { + actions?: Record; + runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record }> }; + }; + const refs = new Map(); + for (const [action, ref] of Object.entries(record.actions ?? {})) { + refs.set(`${action}:${ref}`, { action, ref }); + } + for (const profile of record.runtimeProfiles?.lifecycleProfiles ?? []) { + for (const [action, ref] of Object.entries(profile.actionRefs ?? {})) { + refs.set(`${action}:${ref}`, { action, ref }); + } + } + return [...refs.values()]; +} + +function validateDependencyPlans(manifest: unknown): string[] { + if (typeof manifest !== "object" || manifest === null) { + return []; + } + const plans = (manifest as { runtimeProfiles?: { installPlans?: Array<{ key?: string; steps?: Array<{ type?: string; downloadRef?: string }> }> } }).runtimeProfiles?.installPlans ?? []; + const errors: string[] = []; + for (const [planIndex, plan] of plans.entries()) { + for (const [stepIndex, step] of (plan.steps ?? []).entries()) { + if (step.type !== "verified-download" || !step.downloadRef) { + continue; + } + try { + const parsed = new URL(step.downloadRef); + const host = parsed.hostname.toLowerCase(); + const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || host === "localhost" || host.endsWith(".localhost") || privateIPv4 || host === "::1") { + errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: host is not approved for dependency download`); + } + } catch { + errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: URL is invalid`); + } + } + } + return errors; +} + +function validateClientManagerProfiles(manifest: unknown): string[] { + if (typeof manifest !== "object" || manifest === null) { + return []; + } + type ClientManagerProfile = { + key?: string; + version?: string; + repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string }; + outputArtifacts?: string[]; + deployment?: { executableRef?: string; requiredRunCapabilities?: string[] }; + lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number }; + health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] }; + compatibility?: { minimumVersion?: string; maximumVersion?: string }; + updatePolicy?: { healthConfirmationSeconds?: number }; + }; + const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? []; + const errors: string[] = []; + const parseVersion = (value: string | undefined): number[] | undefined => { + const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; + }; + const compareVersions = (left: number[], right: number[]): number => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) { + return left[index] - right[index]; + } + } + return 0; + }; + + for (const [profileIndex, profile] of profiles.entries()) { + const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`; + const policy = profile.repository?.revisionPolicy; + if (policy === "pinned" && !profile.repository?.revision) { + errors.push(`${location}.repository.revision: required for pinned revision policy`); + } + if (policy === "branch" && !profile.repository?.branch) { + errors.push(`${location}.repository.branch: required for branch revision policy`); + } + if (policy === "tag" && !profile.repository?.tag) { + errors.push(`${location}.repository.tag: required for tag revision policy`); + } + if (!profile.deployment) { + continue; + } + if (!profile.version || !parseVersion(profile.version)) { + errors.push(`${location}.version: lifecycle deployment requires a semantic version`); + } + if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) { + errors.push(`${location}.deployment.executableRef: must name one declared output artifact`); + } + const actions = new Set(profile.lifecycle?.actions ?? []); + const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []); + if (!runCapabilities.has("client-manager.deploy")) { + errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`); + } + if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) { + errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`); + } + if (actions.has("update") && !runCapabilities.has("client-manager.update")) { + errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`); + } + if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) { + errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`); + } + if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) { + errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`); + } + const interval = profile.health?.intervalSeconds ?? 0; + const degraded = profile.health?.degradedAfterSeconds ?? 0; + const offline = profile.health?.offlineAfterSeconds ?? 0; + if (degraded < interval * 2 || offline <= degraded) { + errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`); + } + if (profile.health?.mode === "component-heartbeat") { + const required = new Set(profile.health.requiredCapabilities ?? []); + for (const capability of ["component.register", "component.heartbeat", "component.health"]) { + if (!required.has(capability)) { + errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`); + } + } + } + const version = parseVersion(profile.version); + const minimum = parseVersion(profile.compatibility?.minimumVersion); + const maximum = parseVersion(profile.compatibility?.maximumVersion); + if (minimum && maximum && compareVersions(minimum, maximum) > 0) { + errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`); + } + if (version && minimum && compareVersions(version, minimum) < 0) { + errors.push(`${location}.compatibility: profile version is below minimumVersion`); + } + if (version && maximum && compareVersions(version, maximum) > 0) { + errors.push(`${location}.compatibility: profile version exceeds maximumVersion`); + } + if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) { + errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`); + } + } + return errors; +} + export function validateManifestFile(manifestPath: string): string[] { const absoluteManifestPath = path.resolve(rootDir, manifestPath); const manifest = readJson(absoluteManifestPath); @@ -129,6 +303,21 @@ export function validateManifestFile(manifestPath: string): string[] { } errors.push(...scanUnsafeValues(manifest, "manifest")); + errors.push(...validateDependencyPlans(manifest)); + errors.push(...validateClientManagerProfiles(manifest)); + + for (const declaration of referencedLifecycleActions(manifest)) { + if (!isSafeRelativeJsonRef(declaration.ref)) { + errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`); + continue; + } + const actionPath = path.resolve(manifestDir, declaration.ref); + if (!fs.existsSync(actionPath)) { + errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`); + continue; + } + errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action)); + } if (typeof manifest === "object" && manifest !== null && "server" in manifest) { const server = (manifest as { server?: { createFormSchema?: string } }).server; diff --git a/plugins/sdk/bridge-contract.md b/plugins/sdk/bridge-contract.md index b192c2e..40d7e94 100644 --- a/plugins/sdk/bridge-contract.md +++ b/plugins/sdk/bridge-contract.md @@ -13,7 +13,7 @@ Plugins use the platform bridge for every privileged action. - `run.distribution.request`: request platform-mediated run package generation, download, key reset, or self-update orchestration. - `dependencies.request`: request typed dependency checks or approved install plans declared by the plugin runtime profile. - `logs.backfill.request`: request historical log backfill for a declared log source. -- `client-manager.request`: request generation, download, or key reset for a plugin-declared companion client manager. +- `client-manager.request`: request generation/download/key reset or a typed status, deploy, start, stop, restart, update, rollback, session-revoke, retry, or uninstall operation for a plugin-declared companion client manager. - `ai.invoke`: request platform-mediated AI assistance. - `theme.tokens`: read safe platform theme tokens. @@ -29,7 +29,9 @@ Artifact open requests use `createArtifactOpenRequest` with an artifact ID that Remote access requests use `createRemoteAccessRequest` with a plugin-declared `remote.*` capability, logical target key, optional scoped `input://` or `artifact://` ref, and idempotency key. The SDK never accepts FTP passwords, rsync endpoints, database DSNs, RCON passwords, run sockets, or raw host paths in these envelopes. -Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. These helpers carry operation names, logical profile keys, target OS/architecture, artifact IDs, cursors, and idempotency keys only; raw run keys and client-manager keys are written only into generated packages by platform services. +Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. Client-manager lifecycle envelopes carry only operation names, logical profile/installation IDs, target OS/architecture, artifact IDs, expected deployment generations, and idempotency keys. `parseClientManagerLifecycleStatus` whitelists safe state, version, health, job, artifact, and action fields. Raw run/client-manager keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields. + +Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job. ## Forbidden Data diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index d9fd28b..b60ebc1 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -36,6 +36,11 @@ export type RunCapability = | "remote.run.db.sqlite.query" | "remote.run.logs.transfer" | "remote.run.rcon.command" + | "client-manager.deploy" + | "client-manager.control" + | "client-manager.update" + | "client-manager.rollback" + | "client-manager.uninstall" | "artifacts.read" | "artifacts.write" | "ai.invoke"; @@ -145,6 +150,7 @@ export type PluginDependencyActionPayload = Record & { operation: "check" | "install"; probeKey?: string; planKey?: string; + planDigest?: string; idempotencyKey: string; }; @@ -156,11 +162,26 @@ export type PluginLogBackfillPayload = Record & { }; export type PluginClientManagerPayload = Record & { - operation: "generate" | "download" | "reset-key"; + operation: + | "generate" + | "download" + | "reset-key" + | "status" + | "deploy" + | "start" + | "stop" + | "restart" + | "update" + | "rollback" + | "revoke-session" + | "retry" + | "uninstall"; profileKey: string; targetOS?: RuntimePlatform; targetArch?: RuntimeArch; artifactId?: string; + installationId?: string; + expectedDeploymentGeneration?: string; idempotencyKey: string; }; @@ -243,6 +264,7 @@ export interface RuntimeTransportProfile { export interface RuntimeClientManagerProfile { key: string; displayName?: string; + version?: string; repository: { url: string; revisionPolicy: "pinned" | "branch" | "tag"; @@ -258,6 +280,36 @@ export interface RuntimeClientManagerProfile { }; configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>; outputArtifacts: string[]; + deployment?: { + mode: "run-supervised"; + executableRef: string; + arguments?: string[]; + autoStart?: boolean; + requiredRunCapabilities: Array<"client-manager.deploy" | "client-manager.control" | "client-manager.update" | "client-manager.rollback" | "client-manager.uninstall">; + }; + lifecycle?: { + actions: Array<"start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall">; + startupTimeoutSeconds: number; + stopTimeoutSeconds: number; + }; + health?: { + mode: "component-heartbeat" | "process"; + intervalSeconds: number; + degradedAfterSeconds: number; + offlineAfterSeconds: number; + requiredCapabilities: Array<"component.register" | "component.heartbeat" | "component.health" | "component.control" | "game-client.bridge" | "logs.stream">; + }; + compatibility?: { + minimumVersion?: string; + maximumVersion?: string; + allowDowngrade: boolean; + }; + updatePolicy?: { + strategy: "manual-staged"; + requireApproval: true; + healthConfirmationSeconds: number; + retainPrevious: true; + }; } export interface GamePluginRuntimeProfiles { @@ -283,6 +335,24 @@ export interface PluginArtifactReference { storageBehavior?: string; } +export interface PluginClientManagerLifecycleStatus { + installationId: string; + profileKey: string; + status: string; + phase?: string; + targetOS?: RuntimePlatform; + targetArch?: RuntimeArch; + version?: string; + previousVersion?: string; + artifactId?: string; + currentJobId?: string; + deploymentGeneration?: number; + health?: string; + healthReason?: string; + lastSeenAt?: string; + actions: string[]; +} + export type PluginBridgeExecutionResponse = Record> = { requestId: string; pluginId: string; @@ -315,6 +385,17 @@ export const pluginBridgeActionPolicies: Record; + timeoutMs?: number; + stopTimeoutMs?: number; +} + export type GamePluginActions = Partial> & { install: string; start: string; @@ -507,8 +588,12 @@ export function createDependencyActionRequest(input: { operation: PluginDependencyActionPayload["operation"]; probeKey?: string; planKey?: string; + planDigest?: string; idempotencyKey: string; }): PluginBridgeExecutionRequest { + if (input.operation === "install" && !/^sha256:[a-fA-F0-9]{64}$/.test(input.planDigest ?? "")) { + throw new Error("dependency install requires the reviewed plan SHA-256 digest"); + } return createBridgeExecutionRequest({ requestId: input.requestId, context: input.context, @@ -517,6 +602,7 @@ export function createDependencyActionRequest(input: { operation: input.operation, probeKey: input.probeKey ?? "", planKey: input.planKey ?? "", + planDigest: input.planDigest ?? "", idempotencyKey: input.idempotencyKey } }); @@ -551,12 +637,16 @@ export function createClientManagerRequest(input: { targetOS?: RuntimePlatform; targetArch?: RuntimeArch; artifactId?: string; + installationId?: string; + expectedDeploymentGeneration?: number; idempotencyKey: string; }): PluginBridgeExecutionRequest { const payload: PluginClientManagerPayload = { operation: input.operation, profileKey: input.profileKey, artifactId: input.artifactId ?? "", + installationId: input.installationId ?? "", + expectedDeploymentGeneration: typeof input.expectedDeploymentGeneration === "number" ? String(input.expectedDeploymentGeneration) : "", idempotencyKey: input.idempotencyKey }; if (input.targetOS) { @@ -605,6 +695,52 @@ export function parseArtifactReference(result: Record | undefine return reference; } +export function parseClientManagerLifecycleStatus(result: Record | undefined): PluginClientManagerLifecycleStatus | undefined { + if (!result) { + return undefined; + } + const deploymentGeneration = Number(result.deploymentGeneration ?? "0"); + const values = [ + result.installationId, + result.profileKey, + result.status, + result.phase, + result.targetOS, + result.targetArch, + result.version, + result.previousVersion, + result.artifactId, + result.currentJobId, + result.health, + result.healthReason, + result.lastSeenAt, + result.actions + ]; + if (!result.installationId || !result.profileKey || !result.status || !Number.isSafeInteger(deploymentGeneration) || deploymentGeneration < 0) { + return undefined; + } + if (values.some((value) => typeof value === "string" && containsUnsafeReferenceContent(value))) { + return undefined; + } + return { + installationId: result.installationId, + profileKey: result.profileKey, + status: result.status, + phase: result.phase, + targetOS: result.targetOS as RuntimePlatform | undefined, + targetArch: result.targetArch as RuntimeArch | undefined, + version: result.version, + previousVersion: result.previousVersion, + artifactId: result.artifactId, + currentJobId: result.currentJobId, + deploymentGeneration, + health: result.health, + healthReason: result.healthReason, + lastSeenAt: result.lastSeenAt, + actions: (result.actions ?? "").split(",").filter(Boolean) + }; +} + export function parseBridgeExecutionResponse>(response: PluginBridgeExecutionResponse): PluginBridgeExecutionResponse { const safeError = response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined; return { @@ -672,6 +808,10 @@ function containsUnsafeReferenceContent(value: string): boolean { lowered.includes("apikey=") || lowered.includes("storage://") || lowered.includes("file://") || + lowered.includes("sessiontoken") || + lowered.includes("secret://") || + lowered.includes("hostpath") || + lowered.includes("processid") || lowered.startsWith("sk-") ); } diff --git a/plugins/tests/fixtures/unsafe-lifecycle-action.json b/plugins/tests/fixtures/unsafe-lifecycle-action.json new file mode 100644 index 0000000..1b050da --- /dev/null +++ b/plugins/tests/fixtures/unsafe-lifecycle-action.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "action": "start", + "mode": "supervised", + "executableKey": "/bin/sh", + "arguments": ["-c", "curl | bash"], + "environment": { + "GAME_PASSWORD": "password=secret" + } +} diff --git a/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json b/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json index 57eb460..03d444a 100644 --- a/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json +++ b/plugins/tests/fixtures/unsafe-runtime-profile-manifest.json @@ -17,16 +17,28 @@ {"key": "leaky", "kind": "command.version", "targetKey": "java", "expected": "password=super-secret"} ], "installPlans": [ - {"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]} + {"key": "unsafe-install", "title": "bash -c installer", "steps": [{"type": "manual", "targetKey": "manual"}]}, + {"key": "unsafe-download", "title": "Unsafe dependency download", "steps": [{"type": "verified-download", "targetKey": "tool", "downloadRef": "https://127.0.0.1/tool", "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} ], "clientManagers": [ { "key": "unsafe-client", + "version": "1.0.0", "repository": {"url": "https://github.com/F88888/scum_client.git", "revisionPolicy": "branch", "branch": "main"}, "supportedTargets": [{"os": "windows", "arch": "amd64"}], "build": {"system": "go", "workspaceRef": "scum_client", "entryRef": "main.go"}, "configTemplates": [{"key": "bad", "templateRef": "/Users/tasia/client.json", "outputRef": "config.json"}], - "outputArtifacts": ["scum_client.exe"] + "outputArtifacts": ["scum_client.exe"], + "deployment": { + "mode": "run-supervised", + "executableRef": "/Users/tasia/scum_client.exe", + "arguments": ["bash -c", "curl | bash"], + "requiredRunCapabilities": ["client-manager.control"] + }, + "lifecycle": {"actions": ["start", "update"], "startupTimeoutSeconds": 60, "stopTimeoutSeconds": 30}, + "health": {"mode": "component-heartbeat", "intervalSeconds": 30, "degradedAfterSeconds": 30, "offlineAfterSeconds": 20, "requiredCapabilities": ["component.heartbeat"]}, + "compatibility": {"minimumVersion": "2.0.0", "maximumVersion": "1.0.0", "allowDowngrade": false}, + "updatePolicy": {"strategy": "manual-staged", "requireApproval": true, "healthConfirmationSeconds": 5, "retainPrevious": true} } ] }, diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 2566f8b..eed6990 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -15,12 +15,15 @@ import { createRunDistributionRequest, hasPluginPermission, parseArtifactReference, + parseClientManagerLifecycleStatus, parseBridgeExecutionResponse, parseAIInvocationResponse, type GamePluginManifest, + type RuntimeClientManagerProfile, + type PluginLifecycleActionDeclaration, type PluginBridgeContext } from "../sdk/index.js"; -import { validateManifestFile } from "../scripts/validate-manifest.js"; +import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js"; describe("plugin manifest validation", () => { it("accepts the development example manifest", () => { @@ -54,6 +57,24 @@ describe("plugin manifest validation", () => { expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true); expect(errors.some((error) => error.includes("raw host path"))).toBe(true); expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true); + expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true); + expect(errors.some((error) => error.includes("client-manager.deploy is required"))).toBe(true); + expect(errors.some((error) => error.includes("offline threshold"))).toBe(true); + expect(errors.some((error) => error.includes("profile version is below minimumVersion"))).toBe(true); + }); + + it("validates typed lifecycle declarations and rejects shell/path escapes", () => { + const declaration: PluginLifecycleActionDeclaration = { + version: 1, + action: "start", + mode: "supervised", + executableKey: "bin/game-server", + arguments: ["--foreground"] + }; + expect(declaration.action).toBe("start"); + const errors = validateLifecycleActionFile("tests/fixtures/unsafe-lifecycle-action.json", "start"); + expect(errors.some((error) => error.includes("pattern") || error.includes("arbitrary shell"))).toBe(true); + expect(errors.some((error) => error.includes("raw credential"))).toBe(true); }); }); @@ -323,6 +344,24 @@ describe("plugin SDK", () => { }); it("types runtime profile declarations without raw credentials", () => { + const clientManager: RuntimeClientManagerProfile = { + key: "safe-client-manager", + version: "1.2.3", + repository: { url: "https://github.com/example/safe-client.git", revisionPolicy: "pinned", revision: "0123456789abcdef" }, + supportedTargets: [{ os: "linux", arch: "amd64" }], + build: { system: "go", entryRef: "cmd/client/main.go" }, + outputArtifacts: ["safe-client"], + deployment: { + mode: "run-supervised", + executableRef: "safe-client", + arguments: ["--config", "config.json"], + requiredRunCapabilities: ["client-manager.deploy", "client-manager.control", "client-manager.update", "client-manager.rollback", "client-manager.uninstall"] + }, + lifecycle: { actions: ["start", "stop", "restart", "status", "update", "rollback", "uninstall"], startupTimeoutSeconds: 30, stopTimeoutSeconds: 15 }, + health: { mode: "component-heartbeat", intervalSeconds: 15, degradedAfterSeconds: 45, offlineAfterSeconds: 120, requiredCapabilities: ["component.register", "component.heartbeat", "component.health"] }, + compatibility: { minimumVersion: "1.0.0", allowDowngrade: false }, + updatePolicy: { strategy: "manual-staged", requireApproval: true, healthConfirmationSeconds: 60, retainPrevious: true } + }; const manifest: GamePluginManifest = { id: "game.runtime", name: "Runtime Fixture", @@ -335,11 +374,13 @@ describe("plugin SDK", () => { discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }], dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }], logSources: [{ key: "console", kind: "process.stdout", streamKey: "console", cursorKind: "sequence" }], - transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }] + transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }], + clientManagers: [clientManager] } }; expect(manifest.runtimeProfiles?.discovery?.[0].targetKey).toBe("java"); + expect(manifest.runtimeProfiles?.clientManagers?.[0].deployment?.requiredRunCapabilities).toContain("client-manager.deploy"); expect(JSON.stringify(manifest)).not.toContain("password="); }); @@ -360,6 +401,11 @@ describe("plugin SDK", () => { action: "dependencies.request", payload: { operation: "check", probeKey: "steamcmd" } }); + expect(createDependencyActionRequest({ requestId: "dep-2", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}`, idempotencyKey: "idem-dep-install" })).toMatchObject({ + action: "dependencies.request", + payload: { operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}` } + }); + expect(() => createDependencyActionRequest({ requestId: "dep-unsafe", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", idempotencyKey: "idem-dep-unsafe" })).toThrow(/reviewed plan SHA-256 digest/); expect(createLogBackfillRequest({ requestId: "logs-1", context, sourceKey: "chat-log", limit: 500, idempotencyKey: "idem-logs" })).toMatchObject({ action: "logs.backfill.request", payload: { sourceKey: "chat-log", limit: "500" } @@ -369,6 +415,31 @@ describe("plugin SDK", () => { payload: { operation: "generate", profileKey: "scum-client-manager" } }); expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret"); + expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "scum-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({ + action: "client-manager.request", + payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" } + }); + expect(parseClientManagerLifecycleStatus({ + installationId: "cm-install-1", + profileKey: "scum-client-manager", + status: "online", + phase: "healthy", + targetOS: "windows", + targetArch: "amd64", + version: "1.0.0", + artifactId: "artifact-1", + deploymentGeneration: "2", + health: "healthy", + actions: "stop,restart,update,uninstall" + })).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] }); + expect(parseClientManagerLifecycleStatus({ + installationId: "cm-install-1", + profileKey: "scum-client-manager", + status: "online", + deploymentGeneration: "2", + actions: "stop", + healthReason: "Bearer stolen-session" + })).toBeUndefined(); }); }); diff --git a/scripts/local-debug-env.sh b/scripts/local-debug-env.sh index 4a6d8a8..2961bcc 100755 --- a/scripts/local-debug-env.sh +++ b/scripts/local-debug-env.sh @@ -20,6 +20,10 @@ export PLATFORM_DATA_DIR="${PLATFORM_DATA_DIR:-$LOCAL_DEBUG_ROOT/platform}" export PLATFORM_METADATA_PATH="${PLATFORM_METADATA_PATH:-$PLATFORM_DATA_DIR/metadata.json}" export PLATFORM_LOG_BODY_BACKEND="${PLATFORM_LOG_BODY_BACKEND:-file}" export PLATFORM_LOG_DIR="${PLATFORM_LOG_DIR:-$PLATFORM_DATA_DIR/logs}" +export PLATFORM_ARTIFACT_DIR="${PLATFORM_ARTIFACT_DIR:-$PLATFORM_DATA_DIR/artifacts}" +export PLATFORM_BOOTSTRAP_ADMIN_EMAIL="${PLATFORM_BOOTSTRAP_ADMIN_EMAIL:-operator.local@example.test}" +export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}" +export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}" export RUN_REPO_DIR="${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}" export RUN_MODE="${RUN_MODE:-worker}" diff --git a/scripts/local-debug-smoke.sh b/scripts/local-debug-smoke.sh index d7a73bf..ce218ba 100755 --- a/scripts/local-debug-smoke.sh +++ b/scripts/local-debug-smoke.sh @@ -38,7 +38,7 @@ wait_for_url() { } start_self_hosted_stack() { - mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$GOCACHE" + mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$GOCACHE" trap cleanup_self_started EXIT printf 'self-starting platform for local debug smoke\n' @@ -52,6 +52,10 @@ start_self_hosted_stack() { PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \ PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \ PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \ + PLATFORM_ARTIFACT_DIR="$PLATFORM_ARTIFACT_DIR" \ + PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \ + PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \ + PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \ go run ./cmd/platform ) >"$LOCAL_DEBUG_LOG_DIR/platform.log" 2>&1 & SELF_STARTED_PIDS+=("$!") @@ -103,8 +107,9 @@ json_post() { local url="$1" local body_file="$2" local output_file="$3" + shift 3 local status - status="$(curl -sS -H 'Content-Type: application/json' --data-binary "@$body_file" "$url" -o "$output_file" -w '%{http_code}')" + status="$(curl -sS -H 'Content-Type: application/json' "$@" --data-binary "@$body_file" "$url" -o "$output_file" -w '%{http_code}')" case "$status" in 2*) return 0 @@ -152,6 +157,61 @@ json_id() { node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const id=data.instance?.id || data.serverInstance?.id || data.id || ((data.items||[])[0]||{}).id; if (!id) process.exit(2); process.stdout.write(id);' "$1" } +wait_for_distribution_build() { + local distribution_file="$1" + local job_file="$2" + local artifact_file="$3" + local job_id + local artifact_id + job_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.buildJobId) process.exit(2); process.stdout.write(data.buildJobId);' "$distribution_file")" + artifact_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.artifactId) process.exit(2); process.stdout.write(data.artifactId);' "$distribution_file")" + rm -f "$job_file" "$artifact_file" + + for _ in $(seq 1 60); do + json_get "$API_URL/jobs/$job_id" "$job_file" "${AUTH_HEADER[@]}" || true + if [[ ! -s "$job_file" ]]; then + sleep 1 + continue + fi + if node - "$job_file" <<'NODE'; then +const fs = require("fs"); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +process.exit(job.state === "succeeded" && job.resultRef ? 0 : 1); +NODE + break + fi + if node - "$job_file" <<'NODE'; then +const fs = require("fs"); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +process.exit(job.state === "failed" || job.state === "cancelled" ? 0 : 1); +NODE + printf 'distribution build job reached terminal failure\n' >&2 + sed -n '1,120p' "$job_file" >&2 + exit 1 + fi + sleep 1 + done + + json_get "$API_URL/jobs/$job_id" "$job_file" "${AUTH_HEADER[@]}" + json_get "$API_URL/artifacts/$artifact_id" "$artifact_file" "${AUTH_HEADER[@]}" + node - "$job_file" "$artifact_file" "$artifact_id" <<'NODE' +const fs = require("fs"); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const artifact = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); +const artifactId = process.argv[4]; +if (job.state !== "succeeded" || job.resultRef !== `artifact://${artifactId}`) { + console.error("expected distribution build job to succeed with artifact result"); + console.error(JSON.stringify(job, null, 2)); + process.exit(1); +} +if (artifact.id !== artifactId || artifact.state !== "available" || !/^sha256:/.test(artifact.checksum || "")) { + console.error("expected available distribution artifact with sha256 checksum"); + console.error(JSON.stringify(artifact, null, 2)); + process.exit(1); +} +NODE +} + printf 'checking platform health at %s\n' "$PLATFORM_URL" json_get "$PLATFORM_URL/healthz" "$WORK_DIR/health.json" require_file_contains "$WORK_DIR/health.json" '"status"[[:space:]]*:[[:space:]]*"ok"' @@ -159,7 +219,7 @@ require_file_contains "$WORK_DIR/health.json" '"status"[[:space:]]*:[[:space:]]* cat >"$WORK_DIR/login.request.json" <<'JSON' {"account":"operator.local@example.test","password":"operator-local"} JSON -json_post "$API_URL/auth/login" "$WORK_DIR/login.request.json" "$WORK_DIR/login.response.json" +json_post "$API_URL/auth/login" "$WORK_DIR/login.request.json" "$WORK_DIR/login.response.json" -H 'X-Auth-Token-Response: bearer' SESSION_ID="$(session_token_from_login "$WORK_DIR/login.response.json")" AUTH_HEADER=(-H "Authorization: Bearer $SESSION_ID") require_file_contains "$WORK_DIR/login.response.json" '"status"[[:space:]]*:[[:space:]]*"active"' @@ -356,7 +416,19 @@ curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-generate.response.json" require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"' require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"artifactId"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha' -require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"checksum"[[:space:]]*:[[:space:]]*"sha256:' +require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"buildJobId"[[:space:]]*:[[:space:]]*"job-distribution-build' +node - "$WORK_DIR/scum-alpha-run-generate.response.json" <<'NODE' +const fs = require("fs"); +const distribution = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if (distribution.status !== "building" && distribution.status !== "available") { + console.error("expected run distribution to be building or available"); + console.error(JSON.stringify(distribution, null, 2)); + process.exit(1); +} +NODE +wait_for_distribution_build "$WORK_DIR/scum-alpha-run-generate.response.json" "$WORK_DIR/scum-alpha-run-build-job.response.json" "$WORK_DIR/scum-alpha-run-build-artifact.response.json" +reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.response.json" +reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-artifact.response.json" printf 'checking jobs, logs, artifacts, and marketplace refs\n' json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}" diff --git a/scripts/local-debug-start.sh b/scripts/local-debug-start.sh index 451f33d..933df1a 100755 --- a/scripts/local-debug-start.sh +++ b/scripts/local-debug-start.sh @@ -130,6 +130,9 @@ start_service platform "$ROOT_DIR/platform" env \ PLATFORM_METADATA_PATH="$PLATFORM_METADATA_PATH" \ PLATFORM_LOG_BODY_BACKEND="$PLATFORM_LOG_BODY_BACKEND" \ PLATFORM_LOG_DIR="$PLATFORM_LOG_DIR" \ + PLATFORM_BOOTSTRAP_ADMIN_EMAIL="$PLATFORM_BOOTSTRAP_ADMIN_EMAIL" \ + PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_BOOTSTRAP_ADMIN_PASSWORD" \ + PLATFORM_SECRET_ENVELOPE_KEY="$PLATFORM_SECRET_ENVELOPE_KEY" \ go run ./cmd/platform wait_for_url platform "$(local_debug_platform_url)/healthz"