feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
+9
View File
@@ -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
+7 -1
View File
@@ -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.
+75
View File
@@ -0,0 +1,75 @@
package api
import (
"net/http"
"strings"
"browser.local/platform/domain"
"browser.local/platform/service"
)
func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicAPIRequest(r) || runServiceRequest(r) {
next.ServeHTTP(w, r)
return
}
user, err := h.core.GetCurrentUser(bearerToken(r))
if err != nil {
writeServiceError(w, err)
return
}
if platformAdminRequest(r) && !apiPlatformAdmin(user) {
writeServiceError(w, service.ErrForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func publicAPIRequest(r *http.Request) bool {
path := r.URL.Path
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
return true
}
if r.Method != http.MethodGet {
return false
}
return path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") ||
path == "/api/v1/plugin-marketplace/plugins" || strings.HasPrefix(path, "/api/v1/plugin-marketplace/plugins/")
}
func runServiceRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/v1/run/control/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/jobs/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/logs/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/artifacts/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/metrics/")
}
func platformAdminRequest(r *http.Request) bool {
path := r.URL.Path
if path == "/api/v1/users/current" || strings.HasPrefix(path, "/api/v1/users/current/") {
return false
}
if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") ||
path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") ||
path == "/api/v1/metrics/platform" ||
path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") ||
path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") {
return true
}
if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) {
return true
}
return r.Method == http.MethodPost && (path == "/api/v1/jobs" || path == "/api/v1/artifacts" || path == "/api/v1/log-streams")
}
func apiPlatformAdmin(user domain.User) bool {
for _, role := range user.Roles {
if role == "platform-admin" || role == "admin" || role == "platformadmin" {
return true
}
}
return false
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if err := core.SeedLocalPlatformAdmin(); err != nil {
t.Fatalf("seed admin: %v", err)
}
for _, user := range []domain.User{
{ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
{ID: "user-other", DisplayName: "Other", Email: "other@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
} {
if _, err := core.CreateUser(user); err != nil {
t.Fatalf("create user %s: %v", user.ID, err)
}
}
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatalf("create plugin: %v", err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{
ID: "server-owner", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owner Server",
OwnerUserID: "user-owner", State: domain.ServerInstanceStateReady, ConfigVersion: 1,
}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := core.CreateJob(domain.Job{
ID: "job-owner", ServerInstanceID: "server-owner", RunEndpointID: "run-local",
Capability: "process.start", IdempotencyKey: "job-owner",
}); err != nil {
t.Fatalf("create job: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", ""), http.StatusUnauthorized, errorCodeUnauthorized)
ownerAuth, err := core.LoginUser(domain.UserLogin{Account: "owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login owner: %v", err)
}
otherAuth, err := core.LoginUser(domain.UserLogin{Account: "other@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login other: %v", err)
}
ownerSession := ownerAuth.SessionID
otherSession := otherAuth.SessionID
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-owner", ownerSession)
if jobs.Count != 1 || jobs.Items[0].ID != "job-owner" {
t.Fatalf("owner did not receive own jobs: %+v", jobs)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", "", otherSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/jobs/job-owner/cancel", dto.RunJobCancelRequestBody{Reason: "cross-owner"}, otherSession), http.StatusForbidden, errorCodeForbidden)
encodedJobs, err := json.Marshal(jobs)
if err != nil {
t.Fatalf("encode safe job projection: %v", err)
}
for _, forbidden := range []string{"leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket"} {
if strings.Contains(string(encodedJobs), forbidden) {
t.Fatalf("job projection exposed forbidden field %q: %s", forbidden, encodedJobs)
}
}
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/game-plugins", validGamePluginRequest(), ownerSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/server.scum/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}), http.StatusUnauthorized, errorCodeUnauthorized)
}
func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
token := "run-session-secret"
stamp := time.Now().UTC()
hash := sha256.Sum256([]byte(token))
if err := store.RunControlSessions().Create(domain.RunControlSession{
RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive,
Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15,
CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true,
}); err != nil {
t.Fatalf("create Run session: %v", err)
}
router := NewTestRouterWithCore(core)
request := dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local", SessionToken: token, Version: "0.1.1", Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal heartbeat: %v", err)
}
unsigned := performRequest(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
signed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertStatus(t, signed, http.StatusOK)
replayed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertErrorResponse(t, replayed, http.StatusUnauthorized, errorCodeUnauthorized)
if _, err := core.CreateJob(domain.Job{ID: "job-signed", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed"}); err != nil {
t.Fatalf("create signed job: %v", err)
}
claimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: token, Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal signed claim: %v", err)
}
unsignedClaim := performRequest(t, router, http.MethodPost, "/api/v1/run/jobs/claim", bytes.NewReader(claimBody))
assertErrorResponse(t, unsignedClaim, http.StatusUnauthorized, errorCodeUnauthorized)
signedClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", claimBody, token, "nonce-api-job-1", stamp)
assertStatus(t, signedClaim, http.StatusOK)
staleClaimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: "stale-session", Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal stale claim: %v", err)
}
staleClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", staleClaimBody, "stale-session", "nonce-api-job-2", stamp)
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
privateUpdateBodies := map[string]any{
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
}
nonce := 10
for path, request := range privateUpdateBodies {
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal private Run request for %s: %v", path, err)
}
unsigned := performRequest(t, router, http.MethodPost, path, bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
nonce++
signed := signedRunRequest(t, router, path, body, token, fmt.Sprintf("nonce-api-private-%d", nonce), stamp)
if signed.Code == http.StatusUnauthorized {
t.Fatalf("valid signature was rejected for %s: %s", path, signed.Body.String())
}
}
}
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
t.Helper()
timestamp := strconv.FormatInt(stamp.Unix(), 10)
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(token))
_, _ = mac.Write([]byte(canonical))
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Run-Endpoint", "run-local")
req.Header.Set("X-Run-Timestamp", timestamp)
req.Header.Set("X-Run-Nonce", nonce)
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
func TestSessionRotationRouteRevokesPreviousBearer(t *testing.T) {
router := newTestRouter()
current := createAdminSession(t, router)
rotated := postOKJSONWithAuth[dto.AuthSessionResponse](t, router, "/api/v1/auth/rotate", map[string]string{}, current)
if rotated.SessionID == "" || rotated.SessionID == current || rotated.ExpiresAt.IsZero() {
t.Fatalf("unexpected rotated session: %+v", rotated)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", current), http.StatusUnauthorized, errorCodeUnauthorized)
currentUser := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", rotated.SessionID)
if currentUser.ID != "user-admin" {
t.Fatalf("rotated session resolved unexpected user: %+v", currentUser)
}
}
@@ -0,0 +1,352 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// serverClientManagerLifecycles godoc
// @Summary List Client Manager lifecycle installations
// @Description Returns safe durable lifecycle, health, real job progress, and action availability for the authorized server without component secrets or machine details.
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ClientManagerInstallationListResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers [get]
func (h *coreHandlers) serverClientManagerLifecycles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
views, err := h.core.ListClientManagerLifecyclesForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewsFromDomain(views))
}
// serverClientManagerLifecycleDetail godoc
// @Summary Get one Client Manager lifecycle installation
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Param profileKey path string true "Client Manager profile key"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/{profileKey} [get]
func (h *coreHandlers) serverClientManagerLifecycleDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
view, err := h.core.GetClientManagerLifecycleForSession(bearerToken(r), r.PathValue("id"), r.PathValue("profileKey"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerDeploy godoc
// @Summary Deploy an available Client Manager distribution
// @Description Queues a typed Run deployment after server, endpoint, artifact, target, revision, and key-generation authorization.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerDeployRequest true "Deployment request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/deploy [post]
func (h *coreHandlers) serverClientManagerDeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerDeployRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.DeployClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerControl godoc
// @Summary Control a deployed Client Manager
// @Description Queues a declared typed start, stop, restart, status, or rollback operation.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerControlRequest true "Control request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/control [post]
func (h *coreHandlers) serverClientManagerControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerControlRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.ControlClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUpdateLifecycle godoc
// @Summary Update a Client Manager through staged activation
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUpdateRequest true "Approved staged update request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/update [post]
func (h *coreHandlers) serverClientManagerUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUpdateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UpdateClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRetry godoc
// @Summary Retry a failed Client Manager lifecycle intent
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRetryRequest true "Retry request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/retry [post]
func (h *coreHandlers) serverClientManagerRetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRetryRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RetryClientManagerLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRevokeSession godoc
// @Summary Revoke the active Client Manager component session
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRevokeSessionRequest true "Session revoke request"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/revoke-session [post]
func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRevokeSessionRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RevokeClientManagerSessionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUninstall godoc
// @Summary Safely uninstall a Client Manager
// @Description Stops the supervised process and removes only the controlled installation workspace while retaining audit and distribution history.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUninstallRequest true "Confirmed uninstall request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/uninstall [post]
func (h *coreHandlers) serverClientManagerUninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUninstallRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UninstallClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// runClientManagerLifecycleInput godoc
// @Summary Get fenced Client Manager lifecycle input
// @Description Returns a safe typed lifecycle contract only to the authenticated Run endpoint holding the active job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerLifecycleInputRequest true "Fenced lifecycle input request"
// @Success 200 {object} dto.ClientManagerLifecycleInputResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-input [post]
func (h *coreHandlers) runClientManagerLifecycleInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerLifecycleInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
input, err := h.core.GetClientManagerLifecycleInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleInputFromDomain(input))
}
// runClientManagerLifecycleChunk godoc
// @Summary Read one fenced Client Manager artifact chunk
// @Description Streams a checksummed artifact chunk only to the active typed lifecycle job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.RunUpdateChunkRequest true "Fenced chunk request"
// @Success 200 {object} dto.RunUpdateChunkResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-chunk [post]
func (h *coreHandlers) runClientManagerLifecycleChunk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunUpdateChunkRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
chunk, err := h.core.ReadClientManagerLifecycleChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(chunk))
}
// clientManagerRegister godoc
// @Summary Register an installed Client Manager component
// @Description Verifies a current component-key HMAC, nonce, deployment fence, target, revision, and capabilities before issuing an isolated expiring component session.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerRegisterRequest true "Signed component registration"
// @Success 200 {object} dto.ClientManagerRegisterResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/register [post]
func (h *coreHandlers) clientManagerRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRegisterRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RegisterClientManager(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerRegisterFromDomain(result))
}
// clientManagerHeartbeat godoc
// @Summary Accept a Client Manager component heartbeat
// @Description Accepts monotonic health reports using the isolated component session; Run control credentials are not valid here.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerHeartbeatRequest true "Component heartbeat"
// @Success 200 {object} dto.ClientManagerHeartbeatResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/heartbeat [post]
func (h *coreHandlers) clientManagerHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerHeartbeatRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.AcceptClientManagerHeartbeat(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerHeartbeatFromDomain(result))
}
+3 -2
View File
@@ -78,6 +78,7 @@ func TestRunJobChannelAPIWorkflow(t *testing.T) {
SessionToken: hello.SessionToken,
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)
}
}
+127
View File
@@ -0,0 +1,127 @@
package api
import (
"net/http"
"strconv"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
// runMetricBatchIngest godoc
// @Summary Ingest bounded Run metric samples
// @Description Persists a signed bounded metric batch for server instances owned by the Run endpoint.
// @Tags run-metrics
// @Accept json
// @Produce json
// @Param body body dto.MetricBatchIngestRequest true "Metric batch"
// @Success 200 {object} dto.MetricBatchIngestResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/metrics/batches [post]
func (h *coreHandlers) runMetricBatchIngest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.MetricBatchIngestRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.IngestMetricBatch(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.MetricBatchIngestFromDomain(result))
}
// metricHistory godoc
// @Summary Query persisted server metric samples
// @Description Returns an owner-authorized bounded metric history without machine credentials or fencing state.
// @Tags metrics
// @Produce json
// @Success 200 {object} dto.MetricSampleListResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/metrics/server-instances/history [get]
func (h *coreHandlers) metricHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.core.ListMetricSamplesForSession(bearerToken(r), domain.MetricSampleFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), After: parseQueryTime(r.URL.Query().Get("after")), Before: parseQueryTime(r.URL.Query().Get("before")), Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.MetricSampleListFromDomain(items))
}
// backups godoc
// @Summary Create or list durable backup records
// @Description Creates or returns owner-authorized backup metadata and recovery state.
// @Tags backups
// @Accept json
// @Produce json
// @Success 200 {object} dto.BackupListResponse
// @Success 201 {object} dto.BackupResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/backups [get]
// @Router /api/v1/backups [post]
func (h *coreHandlers) backups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
items, err := h.core.ListBackupsForSession(bearerToken(r), domain.BackupFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), State: domain.BackupState(r.URL.Query().Get("state"))})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.BackupListFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.BackupCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
record, err := h.core.CreateBackupForSession(bearerToken(r), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.BackupFromDomain(record))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
// backupDetail godoc
// @Summary Get one durable backup record
// @Tags backups
// @Produce json
// @Success 200 {object} dto.BackupResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/backups/{id} [get]
func (h *coreHandlers) backupDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
record, err := h.core.GetBackupForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.BackupFromDomain(record))
}
func parseQueryTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339Nano, value)
return parsed
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// remoteAdapters godoc
// @Summary List or request scoped remote adapters
// @Description Lists declared safe adapters or queues an owner-authorized fenced adapter job.
// @Tags remote-adapters
// @Accept json
// @Produce json
// @Success 200 {object} dto.RemoteAdapterDeclarationListResponse
// @Success 202 {object} dto.RemoteAdapterResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/remote-adapters [get]
// @Router /api/v1/server-instances/{id}/remote-adapters [post]
func (h *coreHandlers) remoteAdapters(w http.ResponseWriter, r *http.Request) {
serverInstanceID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
declarations, err := h.core.ListRemoteAdapterDeclarationsForSession(bearerToken(r), serverInstanceID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RemoteAdapterDeclarationsFromDomain(declarations))
case http.MethodPost:
request, err := decodeJSON[dto.RemoteAdapterRequestBody](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RequestRemoteAdapterForSession(bearerToken(r), request.ToDomain(serverInstanceID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.RemoteAdapterFromDomain(result))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
+224 -32
View File
@@ -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
+149 -15
View File
@@ -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"}}}},
},
}
}
+40 -4
View File
@@ -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
}
+37 -14
View File
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin 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
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"browser.local/platform/domain"
"browser.local/platform/service"
)
const (
runEndpointHeader = "X-Run-Endpoint"
runTimestampHeader = "X-Run-Timestamp"
runNonceHeader = "X-Run-Nonce"
runSignatureHeader = "X-Run-Signature"
)
type runRequestEnvelope struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
}
func (h *coreHandlers) requireRunSignature(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.Body == nil {
next(w, r)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
if err != nil {
writeDecodeError(w, err)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
var envelope runRequestEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
next(w, r)
return
}
headerEndpoint := strings.TrimSpace(r.Header.Get(runEndpointHeader))
if headerEndpoint != "" && headerEndpoint != envelope.RunEndpointID {
writeServiceError(w, service.ErrUnauthorized)
return
}
bodySum := sha256.Sum256(body)
err = h.core.AuthorizeRunRequestSignature(domain.RunRequestSignature{
RunEndpointID: envelope.RunEndpointID,
SessionToken: envelope.SessionToken,
Method: r.Method,
Path: r.URL.Path,
Timestamp: strings.TrimSpace(r.Header.Get(runTimestampHeader)),
Nonce: strings.TrimSpace(r.Header.Get(runNonceHeader)),
BodyHash: hex.EncodeToString(bodySum[:]),
Signature: strings.TrimSpace(r.Header.Get(runSignatureHeader)),
})
if err != nil {
writeServiceError(w, err)
return
}
next(w, r)
}
}
+32
View File
@@ -98,3 +98,35 @@ func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceProcessStatus godoc
// @Summary Query supervised server process status
// @Description Queues a typed process.status job for the selected server/profile.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Process status request"
// @Success 202 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/process/status [post]
func (h *coreHandlers) serverInstanceProcessStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.QueryServerInstanceProcessForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerLifecycleFromDomain(result))
}
+74 -1
View File
@@ -7,6 +7,48 @@ import (
"browser.local/platform/dto"
)
// 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)
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
const platformSessionCookieName = "platform_session"
func (h *coreHandlers) writeAuthSession(w http.ResponseWriter, r *http.Request, session domain.AuthSession) {
response := dto.AuthSessionFromDomain(session)
if h.enforceAuthorization && strings.TrimSpace(session.SessionID) != "" {
h.setSessionCookie(w, r, session.SessionID, session.ExpiresAt)
if r.Header.Get("X-Auth-Token-Response") != "bearer" {
response.SessionID = ""
}
}
writeJSON(w, http.StatusOK, response)
}
func (h *coreHandlers) setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: platformSessionCookieName,
Value: token,
Path: "/api/v1",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
}
func (h *coreHandlers) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: platformSessionCookieName,
Value: "",
Path: "/api/v1",
MaxAge: -1,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
}
+26 -14
View File
@@ -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"),
}
}
+10 -1
View File
@@ -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 {
+350
View File
@@ -0,0 +1,350 @@
package domain
import "time"
type ClientManagerLifecycleStatus string
const (
ClientManagerLifecycleRequested ClientManagerLifecycleStatus = "requested"
ClientManagerLifecycleBuilding ClientManagerLifecycleStatus = "building"
ClientManagerLifecycleAvailable ClientManagerLifecycleStatus = "available"
ClientManagerLifecycleDeploying ClientManagerLifecycleStatus = "deploying"
ClientManagerLifecycleInstalled ClientManagerLifecycleStatus = "installed"
ClientManagerLifecycleRegistering ClientManagerLifecycleStatus = "registering"
ClientManagerLifecycleOnline ClientManagerLifecycleStatus = "online"
ClientManagerLifecycleDegraded ClientManagerLifecycleStatus = "degraded"
ClientManagerLifecycleOffline ClientManagerLifecycleStatus = "offline"
ClientManagerLifecycleUpdating ClientManagerLifecycleStatus = "updating"
ClientManagerLifecycleRollingBack ClientManagerLifecycleStatus = "rolling_back"
ClientManagerLifecycleStopping ClientManagerLifecycleStatus = "stopping"
ClientManagerLifecycleUninstalled ClientManagerLifecycleStatus = "uninstalled"
ClientManagerLifecycleFailed ClientManagerLifecycleStatus = "failed"
)
type ClientManagerHealthStatus string
const (
ClientManagerHealthUnknown ClientManagerHealthStatus = "unknown"
ClientManagerHealthHealthy ClientManagerHealthStatus = "healthy"
ClientManagerHealthDegraded ClientManagerHealthStatus = "degraded"
ClientManagerHealthUnhealthy ClientManagerHealthStatus = "unhealthy"
ClientManagerHealthOffline ClientManagerHealthStatus = "offline"
)
type ClientManagerLifecycleOperation string
const (
ClientManagerOperationDeploy ClientManagerLifecycleOperation = "deploy"
ClientManagerOperationStart ClientManagerLifecycleOperation = "start"
ClientManagerOperationStop ClientManagerLifecycleOperation = "stop"
ClientManagerOperationRestart ClientManagerLifecycleOperation = "restart"
ClientManagerOperationStatus ClientManagerLifecycleOperation = "status"
ClientManagerOperationUpdate ClientManagerLifecycleOperation = "update"
ClientManagerOperationRollback ClientManagerLifecycleOperation = "rollback"
ClientManagerOperationUninstall ClientManagerLifecycleOperation = "uninstall"
)
const (
JobCapabilityClientManagerDeploy = "client-manager.deploy"
JobCapabilityClientManagerControl = "client-manager.control"
JobCapabilityClientManagerUpdate = "client-manager.update"
JobCapabilityClientManagerRollback = "client-manager.rollback"
JobCapabilityClientManagerUninstall = "client-manager.uninstall"
)
type ClientManagerSessionStatus string
const (
ClientManagerSessionActive ClientManagerSessionStatus = "active"
ClientManagerSessionRevoked ClientManagerSessionStatus = "revoked"
ClientManagerSessionExpired ClientManagerSessionStatus = "expired"
)
type RuntimeClientManagerDeployment struct {
Mode string
ExecutableRef string
Arguments []string
AutoStart bool
RequiredRunCapabilities []string
}
type RuntimeClientManagerLifecycle struct {
Actions []string
StartupTimeoutSeconds int
StopTimeoutSeconds int
}
type RuntimeClientManagerHealth struct {
Mode string
IntervalSeconds int
DegradedAfterSeconds int
OfflineAfterSeconds int
RequiredCapabilities []string
}
type RuntimeClientManagerCompatibility struct {
MinimumVersion string
MaximumVersion string
AllowDowngrade bool
}
type RuntimeClientManagerUpdatePolicy struct {
Strategy string
RequireApproval bool
HealthConfirmationSeconds int
RetainPrevious bool
}
type ClientManagerInstallation struct {
ID string
ServerInstanceID string
PluginID string
ProfileKey string
RunEndpointID string
TargetOS string
TargetArch string
Status ClientManagerLifecycleStatus
Phase string
DesiredVersion string
ActiveVersion string
PreviousVersion string
DesiredRevision string
ActiveRevision string
PreviousRevision string
DesiredArtifactID string
ActiveArtifactID string
PreviousArtifactID string
Checksum string
KeyGeneration int
DeploymentGeneration int
CurrentJobID string
LastSuccessfulJobID string
LastOperation ClientManagerLifecycleOperation
Health ClientManagerHealthStatus
HealthReason string
LastSeenAt time.Time
LastHeartbeatSequence uint64
Retryable bool
RequiresRedeploy bool
CreatedAt time.Time
UpdatedAt time.Time
InstalledAt time.Time
UninstalledAt time.Time
}
type ClientManagerSession struct {
ID string
InstallationID string
ServerInstanceID string
ProfileKey string
RunEndpointID string
ArtifactID string
KeyGeneration int
DeploymentGeneration int
TokenHash string
Capabilities []string
Status ClientManagerSessionStatus
LastHeartbeatSequence uint64
LastSeenAt time.Time
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt time.Time
}
type ClientManagerRegistrationNonce struct {
ID string
InstallationID string
ExpiresAt time.Time
CreatedAt time.Time
}
type ClientManagerLifecycleActionAvailability struct {
Operation ClientManagerLifecycleOperation
Available bool
Reason string
}
type ClientManagerLifecycleView struct {
Installation ClientManagerInstallation
Distribution ClientManagerDistribution
Job Job
Actions []ClientManagerLifecycleActionAvailability
}
type ClientManagerDeployRequest struct {
ServerInstanceID string
ProfileKey string
DistributionID string
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerControlRequest struct {
ServerInstanceID string
ProfileKey string
Operation ClientManagerLifecycleOperation
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerUpdateRequest struct {
ServerInstanceID string
ProfileKey string
DistributionID string
ExpectedDeploymentGeneration int
Approved bool
IdempotencyKey string
}
type ClientManagerUninstallRequest struct {
ServerInstanceID string
ProfileKey string
ExpectedDeploymentGeneration int
Confirmed bool
IdempotencyKey string
}
type ClientManagerRetryRequest struct {
ServerInstanceID string
ProfileKey string
ExpectedDeploymentGeneration int
IdempotencyKey string
}
type ClientManagerRevokeSessionRequest struct {
ServerInstanceID string
ProfileKey string
Reason string
}
type ClientManagerLifecycleInputRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
}
type ClientManagerLifecycleInput struct {
InstallationID string
ServerInstanceID string
ProfileKey string
Operation ClientManagerLifecycleOperation
ArtifactID string
Checksum string
TargetOS string
TargetArch string
Version string
SourceRevision string
KeyGeneration int
DeploymentGeneration int
ExecutableRef string
Arguments []string
AutoStart bool
StartupTimeoutSeconds int
StopTimeoutSeconds int
HealthConfirmationSeconds int
IdempotencyKey string
}
type ClientManagerRegisterRequest struct {
InstallationID string
ServerInstanceID string
ProfileKey string
ArtifactID string
Version string
SourceRevision string
TargetOS string
TargetArch string
KeyGeneration int
DeploymentGeneration int
Capabilities []string
Timestamp time.Time
Nonce string
Signature string
}
type ClientManagerRegisterResult struct {
Accepted bool
InstallationID string
SessionToken string
ExpiresAt time.Time
HeartbeatEvery int
ServerTime time.Time
}
type ClientManagerHeartbeat struct {
InstallationID string
SessionToken string
Sequence uint64
Health ClientManagerHealthStatus
HealthReason string
Capabilities []string
SentAt time.Time
}
type ClientManagerHeartbeatResult struct {
Accepted bool
InstallationID string
Status ClientManagerLifecycleStatus
Health ClientManagerHealthStatus
NextHeartbeat int
SessionExpiresAt time.Time
ServerTime time.Time
}
type ClientManagerInstallationFilter struct {
ServerInstanceID string
ProfileKey string
RunEndpointID string
Status ClientManagerLifecycleStatus
}
type ClientManagerSessionFilter struct {
InstallationID string
ServerInstanceID string
ProfileKey string
Status ClientManagerSessionStatus
}
type ClientManagerNonceFilter struct {
InstallationID string
ExpiresBefore time.Time
}
func CopyClientManagerInstallation(value ClientManagerInstallation) ClientManagerInstallation {
return value
}
func CopyClientManagerSession(value ClientManagerSession) ClientManagerSession {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
func CopyClientManagerRegistrationNonce(value ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
return value
}
func CopyClientManagerLifecycleView(value ClientManagerLifecycleView) ClientManagerLifecycleView {
value.Installation = CopyClientManagerInstallation(value.Installation)
value.Distribution = CopyClientManagerDistribution(value.Distribution)
value.Job = CopyJob(value.Job)
value.Actions = append([]ClientManagerLifecycleActionAvailability(nil), value.Actions...)
return value
}
func CopyClientManagerLifecycleInput(value ClientManagerLifecycleInput) ClientManagerLifecycleInput {
value.Arguments = CopyStringSlice(value.Arguments)
return value
}
func CopyClientManagerRegisterRequest(value ClientManagerRegisterRequest) ClientManagerRegisterRequest {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
func CopyClientManagerHeartbeat(value ClientManagerHeartbeat) ClientManagerHeartbeat {
value.Capabilities = CopyStringSlice(value.Capabilities)
return value
}
+24 -1
View File
@@ -19,6 +19,9 @@ type RunControlHello struct {
Version string
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
}
+144 -31
View File
@@ -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
}
+185
View File
@@ -0,0 +1,185 @@
package domain
import "time"
// MetricSample is a bounded, platform-owned observation for one server instance.
type MetricSample struct {
ID string
ServerInstanceID string
RunEndpointID string
Online bool
PlayerCount *int
MaxPlayers *int
TPS *float64
LatencyMS *float64
CPUPercent *float64
MemoryPercent *float64
DiskPercent *float64
Source string
CollectedAt time.Time
}
type MetricSampleFilter struct {
ServerInstanceID string
After time.Time
Before time.Time
Limit int
}
type MetricBatchIngest struct {
RunEndpointID string
SessionToken string
Samples []MetricSample
}
type MetricBatchIngestResult struct {
Accepted bool
AcceptedCount int
LatestAt time.Time
ServerTime time.Time
}
type BackupState string
const (
BackupStatePending BackupState = "pending"
BackupStateAvailable BackupState = "available"
BackupStateFailed BackupState = "failed"
BackupStateExpired BackupState = "expired"
)
type BackupRecord struct {
ID string
ServerInstanceID string
ArtifactID string
Checksum string
SizeBytes int64
State BackupState
RecoveryStatus string
RetentionUntil time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type BackupFilter struct {
ServerInstanceID string
State BackupState
}
type RemoteAdapterKind string
const (
RemoteAdapterFTP RemoteAdapterKind = "ftp"
RemoteAdapterRsync RemoteAdapterKind = "rsync"
RemoteAdapterRunFile RemoteAdapterKind = "run-file"
RemoteAdapterRunProcess RemoteAdapterKind = "run-process"
RemoteAdapterDatabase RemoteAdapterKind = "database"
RemoteAdapterRCON RemoteAdapterKind = "rcon"
)
type RemoteAdapterDeclaration struct {
Key string
Kind RemoteAdapterKind
TargetKeys []string
Capabilities []string
TimeoutSeconds int
MaxAttempts int
}
type RemoteAdapterRequest struct {
ServerInstanceID string
DeclarationKey string
TargetKey string
Capability string
TimeoutSeconds int
MaxAttempts int
IdempotencyKey string
}
type RemoteAdapterResult struct {
RequestID string
ServerInstanceID string
DeclarationKey string
TargetKey string
Kind RemoteAdapterKind
Status string
Retryable bool
Message string
ResultRef string
AuditEventID string
CompletedAt time.Time
}
func CopyMetricSample(sample MetricSample) MetricSample {
sample.PlayerCount = copyIntPtr(sample.PlayerCount)
sample.MaxPlayers = copyIntPtr(sample.MaxPlayers)
sample.TPS = copyFloatPtr(sample.TPS)
sample.LatencyMS = copyFloatPtr(sample.LatencyMS)
sample.CPUPercent = copyFloatPtr(sample.CPUPercent)
sample.MemoryPercent = copyFloatPtr(sample.MemoryPercent)
sample.DiskPercent = copyFloatPtr(sample.DiskPercent)
return sample
}
func copyIntPtr(value *int) *int {
if value == nil {
return nil
}
copy := *value
return &copy
}
func copyFloatPtr(value *float64) *float64 {
if value == nil {
return nil
}
copy := *value
return &copy
}
func CopyMetricSamples(samples []MetricSample) []MetricSample {
if samples == nil {
return nil
}
out := make([]MetricSample, len(samples))
for i, sample := range samples {
out[i] = CopyMetricSample(sample)
}
return out
}
func CopyMetricBatchIngest(batch MetricBatchIngest) MetricBatchIngest {
batch.Samples = CopyMetricSamples(batch.Samples)
return batch
}
func CopyBackupRecord(record BackupRecord) BackupRecord { return record }
func CopyBackupRecords(records []BackupRecord) []BackupRecord {
if records == nil {
return nil
}
out := make([]BackupRecord, len(records))
copy(out, records)
return out
}
func CopyRemoteAdapterDeclaration(declaration RemoteAdapterDeclaration) RemoteAdapterDeclaration {
declaration.TargetKeys = CopyStringSlice(declaration.TargetKeys)
declaration.Capabilities = CopyStringSlice(declaration.Capabilities)
return declaration
}
func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []RemoteAdapterDeclaration {
if declarations == nil {
return nil
}
out := make([]RemoteAdapterDeclaration, len(declarations))
for i, declaration := range declarations {
out[i] = CopyRemoteAdapterDeclaration(declaration)
}
return out
}
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { return request }
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
+390 -36
View File
@@ -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
}
+21 -6
View File
@@ -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.
+7
View File
@@ -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
}
+267
View File
@@ -0,0 +1,267 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type ClientManagerDeployRequest struct {
ProfileKey string `json:"profileKey"`
DistributionID string `json:"distributionId"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerControlRequest struct {
ProfileKey string `json:"profileKey"`
Operation string `json:"operation"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerUpdateRequest struct {
ProfileKey string `json:"profileKey"`
DistributionID string `json:"distributionId"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
Approved bool `json:"approved"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerUninstallRequest struct {
ProfileKey string `json:"profileKey"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
Confirmed bool `json:"confirmed"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerRetryRequest struct {
ProfileKey string `json:"profileKey"`
ExpectedDeploymentGeneration int `json:"expectedDeploymentGeneration"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerRevokeSessionRequest struct {
ProfileKey string `json:"profileKey"`
Reason string `json:"reason,omitempty"`
}
type ClientManagerLifecycleActionResponse struct {
Operation string `json:"operation"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type ClientManagerLifecycleJobResponse struct {
ID string `json:"id,omitempty"`
State domain.JobState `json:"state,omitempty"`
Progress JobProgressBody `json:"progress,omitempty"`
Attempt int `json:"attempt,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
type ClientManagerInstallationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Status string `json:"status"`
Phase string `json:"phase,omitempty"`
DesiredVersion string `json:"desiredVersion,omitempty"`
ActiveVersion string `json:"activeVersion,omitempty"`
PreviousVersion string `json:"previousVersion,omitempty"`
DesiredRevision string `json:"desiredRevision,omitempty"`
ActiveRevision string `json:"activeRevision,omitempty"`
PreviousRevision string `json:"previousRevision,omitempty"`
DesiredArtifactID string `json:"desiredArtifactId,omitempty"`
ActiveArtifactID string `json:"activeArtifactId,omitempty"`
PreviousArtifactID string `json:"previousArtifactId,omitempty"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
CurrentJobID string `json:"currentJobId,omitempty"`
LastSuccessfulJobID string `json:"lastSuccessfulJobId,omitempty"`
LastOperation string `json:"lastOperation,omitempty"`
Health string `json:"health"`
HealthReason string `json:"healthReason,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
Retryable bool `json:"retryable"`
RequiresRedeploy bool `json:"requiresRedeploy"`
InstalledAt time.Time `json:"installedAt,omitempty"`
UninstalledAt time.Time `json:"uninstalledAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Distribution *ClientManagerDistributionSummaryResponse `json:"distribution,omitempty"`
Job *ClientManagerLifecycleJobResponse `json:"job,omitempty"`
Actions []ClientManagerLifecycleActionResponse `json:"actions"`
}
type ClientManagerDistributionSummaryResponse struct {
ID string `json:"id"`
ArtifactID string `json:"artifactId"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Checksum string `json:"checksum"`
KeyGeneration int `json:"keyGeneration"`
Status string `json:"status"`
}
type ClientManagerInstallationListResponse struct {
Items []ClientManagerInstallationResponse `json:"items"`
Count int `json:"count"`
}
type ClientManagerLifecycleInputRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type ClientManagerLifecycleInputResponse struct {
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
ProfileKey string `json:"profileKey"`
Operation string `json:"operation"`
ArtifactID string `json:"artifactId,omitempty"`
Checksum string `json:"checksum,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Version string `json:"version,omitempty"`
SourceRevision string `json:"sourceRevision,omitempty"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
ExecutableRef string `json:"executableRef"`
Arguments []string `json:"arguments"`
AutoStart bool `json:"autoStart"`
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ClientManagerRegisterRequest struct {
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
ProfileKey string `json:"profileKey"`
ArtifactID string `json:"artifactId"`
Version string `json:"version"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
Capabilities []string `json:"capabilities"`
Timestamp time.Time `json:"timestamp"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
}
type ClientManagerRegisterResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
ExpiresAt time.Time `json:"expiresAt"`
HeartbeatEvery int `json:"heartbeatEverySeconds"`
ServerTime time.Time `json:"serverTime"`
}
type ClientManagerHeartbeatRequest struct {
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
Sequence uint64 `json:"sequence"`
Health string `json:"health"`
HealthReason string `json:"healthReason,omitempty"`
Capabilities []string `json:"capabilities"`
SentAt time.Time `json:"sentAt"`
}
type ClientManagerHeartbeatResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
Status string `json:"status"`
Health string `json:"health"`
NextHeartbeat int `json:"nextHeartbeatSeconds"`
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
ServerTime time.Time `json:"serverTime"`
}
func (request ClientManagerDeployRequest) ToDomain(serverID string) domain.ClientManagerDeployRequest {
return domain.ClientManagerDeployRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerControlRequest) ToDomain(serverID string) domain.ClientManagerControlRequest {
return domain.ClientManagerControlRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Operation: domain.ClientManagerLifecycleOperation(request.Operation), ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerUpdateRequest) ToDomain(serverID string) domain.ClientManagerUpdateRequest {
return domain.ClientManagerUpdateRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, DistributionID: request.DistributionID, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Approved: request.Approved, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerUninstallRequest) ToDomain(serverID string) domain.ClientManagerUninstallRequest {
return domain.ClientManagerUninstallRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, Confirmed: request.Confirmed, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerRetryRequest) ToDomain(serverID string) domain.ClientManagerRetryRequest {
return domain.ClientManagerRetryRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, ExpectedDeploymentGeneration: request.ExpectedDeploymentGeneration, IdempotencyKey: request.IdempotencyKey}
}
func (request ClientManagerRevokeSessionRequest) ToDomain(serverID string) domain.ClientManagerRevokeSessionRequest {
return domain.ClientManagerRevokeSessionRequest{ServerInstanceID: serverID, ProfileKey: request.ProfileKey, Reason: request.Reason}
}
func (request ClientManagerLifecycleInputRequest) ToDomain() domain.ClientManagerLifecycleInputRequest {
return domain.ClientManagerLifecycleInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
}
func ClientManagerLifecycleInputFromDomain(value domain.ClientManagerLifecycleInput) ClientManagerLifecycleInputResponse {
value = domain.CopyClientManagerLifecycleInput(value)
return ClientManagerLifecycleInputResponse{InstallationID: value.InstallationID, ServerInstanceID: value.ServerInstanceID, ProfileKey: value.ProfileKey, Operation: string(value.Operation), ArtifactID: value.ArtifactID, Checksum: value.Checksum, TargetOS: value.TargetOS, TargetArch: value.TargetArch, Version: value.Version, SourceRevision: value.SourceRevision, KeyGeneration: value.KeyGeneration, DeploymentGeneration: value.DeploymentGeneration, ExecutableRef: value.ExecutableRef, Arguments: value.Arguments, AutoStart: value.AutoStart, StartupTimeoutSeconds: value.StartupTimeoutSeconds, StopTimeoutSeconds: value.StopTimeoutSeconds, HealthConfirmationSeconds: value.HealthConfirmationSeconds, IdempotencyKey: value.IdempotencyKey}
}
func (request ClientManagerRegisterRequest) ToDomain() domain.ClientManagerRegisterRequest {
return domain.ClientManagerRegisterRequest{InstallationID: request.InstallationID, ServerInstanceID: request.ServerInstanceID, ProfileKey: request.ProfileKey, ArtifactID: request.ArtifactID, Version: request.Version, SourceRevision: request.SourceRevision, TargetOS: request.TargetOS, TargetArch: request.TargetArch, KeyGeneration: request.KeyGeneration, DeploymentGeneration: request.DeploymentGeneration, Capabilities: domain.CopyStringSlice(request.Capabilities), Timestamp: request.Timestamp, Nonce: request.Nonce, Signature: request.Signature}
}
func ClientManagerRegisterFromDomain(value domain.ClientManagerRegisterResult) ClientManagerRegisterResponse {
return ClientManagerRegisterResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, SessionToken: value.SessionToken, ExpiresAt: value.ExpiresAt, HeartbeatEvery: value.HeartbeatEvery, ServerTime: value.ServerTime}
}
func (request ClientManagerHeartbeatRequest) ToDomain() domain.ClientManagerHeartbeat {
return domain.ClientManagerHeartbeat{InstallationID: request.InstallationID, SessionToken: request.SessionToken, Sequence: request.Sequence, Health: domain.ClientManagerHealthStatus(request.Health), HealthReason: request.HealthReason, Capabilities: domain.CopyStringSlice(request.Capabilities), SentAt: request.SentAt}
}
func ClientManagerHeartbeatFromDomain(value domain.ClientManagerHeartbeatResult) ClientManagerHeartbeatResponse {
return ClientManagerHeartbeatResponse{Accepted: value.Accepted, InstallationID: value.InstallationID, Status: string(value.Status), Health: string(value.Health), NextHeartbeat: value.NextHeartbeat, SessionExpiresAt: value.SessionExpiresAt, ServerTime: value.ServerTime}
}
func ClientManagerLifecycleViewFromDomain(value domain.ClientManagerLifecycleView) ClientManagerInstallationResponse {
value = domain.CopyClientManagerLifecycleView(value)
installation := value.Installation
response := ClientManagerInstallationResponse{ID: installation.ID, ServerInstanceID: installation.ServerInstanceID, PluginID: installation.PluginID, ProfileKey: installation.ProfileKey, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, Status: string(installation.Status), Phase: installation.Phase, DesiredVersion: installation.DesiredVersion, ActiveVersion: installation.ActiveVersion, PreviousVersion: installation.PreviousVersion, DesiredRevision: installation.DesiredRevision, ActiveRevision: installation.ActiveRevision, PreviousRevision: installation.PreviousRevision, DesiredArtifactID: installation.DesiredArtifactID, ActiveArtifactID: installation.ActiveArtifactID, PreviousArtifactID: installation.PreviousArtifactID, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, CurrentJobID: installation.CurrentJobID, LastSuccessfulJobID: installation.LastSuccessfulJobID, LastOperation: string(installation.LastOperation), Health: string(installation.Health), HealthReason: installation.HealthReason, LastSeenAt: installation.LastSeenAt, Retryable: installation.Retryable, RequiresRedeploy: installation.RequiresRedeploy, InstalledAt: installation.InstalledAt, UninstalledAt: installation.UninstalledAt, UpdatedAt: installation.UpdatedAt}
if value.Distribution.ID != "" {
response.Distribution = &ClientManagerDistributionSummaryResponse{ID: value.Distribution.ID, ArtifactID: value.Distribution.ArtifactID, SourceRevision: value.Distribution.SourceRevision, TargetOS: value.Distribution.TargetOS, TargetArch: value.Distribution.TargetArch, Checksum: value.Distribution.Checksum, KeyGeneration: value.Distribution.KeyGeneration, Status: string(value.Distribution.Status)}
}
if value.Job.ID != "" {
response.Job = &ClientManagerLifecycleJobResponse{ID: value.Job.ID, State: value.Job.State, Progress: JobProgressBody{Percent: value.Job.Progress.Percent, Message: value.Job.Progress.Message}, Attempt: value.Job.Attempt, CreatedAt: value.Job.CreatedAt, UpdatedAt: value.Job.UpdatedAt}
}
response.Actions = make([]ClientManagerLifecycleActionResponse, len(value.Actions))
for i, action := range value.Actions {
response.Actions[i] = ClientManagerLifecycleActionResponse{Operation: string(action.Operation), Available: action.Available, Reason: action.Reason}
}
if response.Actions == nil {
response.Actions = []ClientManagerLifecycleActionResponse{}
}
return response
}
func ClientManagerLifecycleViewsFromDomain(values []domain.ClientManagerLifecycleView) ClientManagerInstallationListResponse {
items := make([]ClientManagerInstallationResponse, len(values))
for i, value := range values {
items[i] = ClientManagerLifecycleViewFromDomain(value)
}
return ClientManagerInstallationListResponse{Items: items, Count: len(items)}
}
+8
View File
@@ -23,6 +23,9 @@ type RunControlHelloRequest struct {
Version string `json:"version"`
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,
}
}
+145
View File
@@ -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)}
}
+226 -48
View File
@@ -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,
}
+165
View File
@@ -0,0 +1,165 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type MetricSampleBody struct {
ID string `json:"id,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
Online bool `json:"online"`
PlayerCount *int `json:"playerCount,omitempty"`
MaxPlayers *int `json:"maxPlayers,omitempty"`
TPS *float64 `json:"tps,omitempty"`
LatencyMS *float64 `json:"latencyMs,omitempty"`
CPUPercent *float64 `json:"cpuPercent,omitempty"`
MemoryPercent *float64 `json:"memoryPercent,omitempty"`
DiskPercent *float64 `json:"diskPercent,omitempty"`
Source string `json:"source"`
CollectedAt time.Time `json:"collectedAt"`
}
type MetricBatchIngestRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
Samples []MetricSampleBody `json:"samples"`
}
type MetricBatchIngestResponse struct {
Accepted bool `json:"accepted"`
AcceptedCount int `json:"acceptedCount"`
LatestAt time.Time `json:"latestAt"`
ServerTime time.Time `json:"serverTime"`
}
type MetricSampleListResponse struct {
Items []MetricSampleBody `json:"items"`
Count int `json:"count"`
}
type BackupCreateRequest struct {
ID string `json:"id,omitempty"`
ServerInstanceID string `json:"serverInstanceId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
State domain.BackupState `json:"state,omitempty"`
RetentionUntil time.Time `json:"retentionUntil,omitempty"`
}
type BackupResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
State domain.BackupState `json:"state"`
RecoveryStatus string `json:"recoveryStatus,omitempty"`
RetentionUntil time.Time `json:"retentionUntil,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupListResponse struct {
Items []BackupResponse `json:"items"`
Count int `json:"count"`
}
type RemoteAdapterDeclarationResponse struct {
Key string `json:"key"`
Kind domain.RemoteAdapterKind `json:"kind"`
TargetKeys []string `json:"targetKeys"`
Capabilities []string `json:"capabilities"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxAttempts int `json:"maxAttempts"`
}
type RemoteAdapterDeclarationListResponse struct {
Items []RemoteAdapterDeclarationResponse `json:"items"`
Count int `json:"count"`
}
type RemoteAdapterRequestBody struct {
DeclarationKey string `json:"declarationKey"`
TargetKey string `json:"targetKey"`
Capability string `json:"capability"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type RemoteAdapterResponse struct {
RequestID string `json:"requestId"`
ServerInstanceID string `json:"serverInstanceId"`
DeclarationKey string `json:"declarationKey"`
TargetKey string `json:"targetKey"`
Kind domain.RemoteAdapterKind `json:"kind"`
Status string `json:"status"`
Retryable bool `json:"retryable"`
Message string `json:"message"`
ResultRef string `json:"resultRef,omitempty"`
AuditEventID string `json:"auditEventId,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
func (request MetricBatchIngestRequest) ToDomain() domain.MetricBatchIngest {
samples := make([]domain.MetricSample, len(request.Samples))
for i, sample := range request.Samples {
samples[i] = metricSampleToDomain(sample)
}
return domain.MetricBatchIngest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, Samples: samples}
}
func MetricBatchIngestFromDomain(result domain.MetricBatchIngestResult) MetricBatchIngestResponse {
return MetricBatchIngestResponse{Accepted: result.Accepted, AcceptedCount: result.AcceptedCount, LatestAt: result.LatestAt, ServerTime: result.ServerTime}
}
func MetricSampleListFromDomain(samples []domain.MetricSample) MetricSampleListResponse {
items := make([]MetricSampleBody, len(samples))
for i, sample := range samples {
items[i] = metricSampleFromDomain(sample)
}
return MetricSampleListResponse{Items: items, Count: len(items)}
}
func (request BackupCreateRequest) ToDomain() domain.BackupRecord {
return domain.BackupRecord{ID: request.ID, ServerInstanceID: request.ServerInstanceID, ArtifactID: request.ArtifactID, Checksum: request.Checksum, SizeBytes: request.SizeBytes, State: request.State, RetentionUntil: request.RetentionUntil}
}
func BackupFromDomain(record domain.BackupRecord) BackupResponse {
return BackupResponse{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
func BackupListFromDomain(records []domain.BackupRecord) BackupListResponse {
items := make([]BackupResponse, len(records))
for i, record := range records {
items[i] = BackupFromDomain(record)
}
return BackupListResponse{Items: items, Count: len(items)}
}
func RemoteAdapterDeclarationsFromDomain(declarations []domain.RemoteAdapterDeclaration) RemoteAdapterDeclarationListResponse {
items := make([]RemoteAdapterDeclarationResponse, len(declarations))
for i, declaration := range declarations {
items[i] = RemoteAdapterDeclarationResponse{Key: declaration.Key, Kind: declaration.Kind, TargetKeys: domain.CopyStringSlice(declaration.TargetKeys), Capabilities: domain.CopyStringSlice(declaration.Capabilities), TimeoutSeconds: declaration.TimeoutSeconds, MaxAttempts: declaration.MaxAttempts}
}
return RemoteAdapterDeclarationListResponse{Items: items, Count: len(items)}
}
func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain.RemoteAdapterRequest {
return domain.RemoteAdapterRequest{ServerInstanceID: serverInstanceID, DeclarationKey: request.DeclarationKey, TargetKey: request.TargetKey, Capability: request.Capability, TimeoutSeconds: request.TimeoutSeconds, MaxAttempts: request.MaxAttempts, IdempotencyKey: request.IdempotencyKey}
}
func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse {
return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, AuditEventID: result.AuditEventID, CompletedAt: result.CompletedAt}
}
func metricSampleToDomain(sample MetricSampleBody) domain.MetricSample {
return domain.MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
func metricSampleFromDomain(sample domain.MetricSample) MetricSampleBody {
return MetricSampleBody{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
+237 -146
View File
@@ -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 &copy
}
func JobListFromDomain(jobs []domain.Job) JobListResponse {
items := make([]JobResponse, len(jobs))
for i, job := range jobs {
+8 -5
View File
@@ -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")
}
}
+243
View File
@@ -0,0 +1,243 @@
package dto
import "browser.local/platform/domain"
type RuntimeTargetBody struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
type RuntimeDiscoveryProbeBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
Expected string `json:"expected,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeLifecycleProfileBody struct {
Key string `json:"key"`
Mode string `json:"mode"`
Capabilities []string `json:"capabilities"`
ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeDependencyProbeBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
MinimumVersion string `json:"minimumVersion,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeInstallStepBody struct {
Type string `json:"type"`
TargetKey string `json:"targetKey"`
PackageManager string `json:"packageManager,omitempty"`
PackageName string `json:"packageName,omitempty"`
Version string `json:"version,omitempty"`
DownloadRef string `json:"downloadRef,omitempty"`
Checksum string `json:"checksum,omitempty"`
}
type RuntimeInstallPlanBody struct {
Key string `json:"key"`
Title string `json:"title"`
Platforms []string `json:"platforms,omitempty"`
Steps []RuntimeInstallStepBody `json:"steps"`
}
type RuntimeLogSourceBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey,omitempty"`
StreamKey string `json:"streamKey"`
CursorKind string `json:"cursorKind,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
}
type RuntimeTransportProfileBody struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey,omitempty"`
Capabilities []string `json:"capabilities"`
}
type RuntimeRepositoryBody struct {
URL string `json:"url"`
RevisionPolicy string `json:"revisionPolicy"`
Branch string `json:"branch,omitempty"`
Tag string `json:"tag,omitempty"`
Revision string `json:"revision,omitempty"`
}
type RuntimeBuildBody struct {
System string `json:"system"`
WorkspaceRef string `json:"workspaceRef,omitempty"`
EntryRef string `json:"entryRef,omitempty"`
}
type RuntimeConfigTemplateBody struct {
Key string `json:"key"`
TemplateRef string `json:"templateRef"`
OutputRef string `json:"outputRef"`
}
type RuntimeClientManagerDeploymentBody struct {
Mode string `json:"mode"`
ExecutableRef string `json:"executableRef"`
Arguments []string `json:"arguments,omitempty"`
AutoStart bool `json:"autoStart,omitempty"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
}
type RuntimeClientManagerLifecycleBody struct {
Actions []string `json:"actions"`
StartupTimeoutSeconds int `json:"startupTimeoutSeconds"`
StopTimeoutSeconds int `json:"stopTimeoutSeconds"`
}
type RuntimeClientManagerHealthBody struct {
Mode string `json:"mode"`
IntervalSeconds int `json:"intervalSeconds"`
DegradedAfterSeconds int `json:"degradedAfterSeconds"`
OfflineAfterSeconds int `json:"offlineAfterSeconds"`
RequiredCapabilities []string `json:"requiredCapabilities"`
}
type RuntimeClientManagerCompatibilityBody struct {
MinimumVersion string `json:"minimumVersion,omitempty"`
MaximumVersion string `json:"maximumVersion,omitempty"`
AllowDowngrade bool `json:"allowDowngrade"`
}
type RuntimeClientManagerUpdatePolicyBody struct {
Strategy string `json:"strategy"`
RequireApproval bool `json:"requireApproval"`
HealthConfirmationSeconds int `json:"healthConfirmationSeconds"`
RetainPrevious bool `json:"retainPrevious"`
}
type RuntimeClientManagerProfileBody struct {
Key string `json:"key"`
DisplayName string `json:"displayName,omitempty"`
Version string `json:"version,omitempty"`
Repository RuntimeRepositoryBody `json:"repository"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
Build RuntimeBuildBody `json:"build"`
ConfigTemplates []RuntimeConfigTemplateBody `json:"configTemplates,omitempty"`
OutputArtifacts []string `json:"outputArtifacts"`
Deployment RuntimeClientManagerDeploymentBody `json:"deployment,omitempty"`
Lifecycle RuntimeClientManagerLifecycleBody `json:"lifecycle,omitempty"`
Health RuntimeClientManagerHealthBody `json:"health,omitempty"`
Compatibility RuntimeClientManagerCompatibilityBody `json:"compatibility,omitempty"`
UpdatePolicy RuntimeClientManagerUpdatePolicyBody `json:"updatePolicy,omitempty"`
}
type GamePluginRuntimeProfilesBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
}
func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimeProfiles {
profiles := domain.GamePluginRuntimeProfiles{}
for _, item := range body.Discovery {
profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.LifecycleProfiles {
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.DependencyProbes {
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.InstallPlans {
plan := domain.RuntimeInstallPlan{Key: item.Key, Title: item.Title, Platforms: domain.CopyStringSlice(item.Platforms)}
for _, step := range item.Steps {
plan.Steps = append(plan.Steps, domain.RuntimeInstallStep{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum})
}
profiles.InstallPlans = append(profiles.InstallPlans, plan)
}
for _, item := range body.LogSources {
profiles.LogSources = append(profiles.LogSources, domain.RuntimeLogSource{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
}
for _, item := range body.TransportProfiles {
profiles.TransportProfiles = append(profiles.TransportProfiles, domain.RuntimeTransportProfile{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: domain.CopyStringSlice(item.Capabilities)})
}
for _, item := range body.ClientManagers {
manager := domain.RuntimeClientManagerProfile{
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
RepositoryURL: item.Repository.URL, RevisionPolicy: item.Repository.RevisionPolicy, Branch: item.Repository.Branch, Tag: item.Repository.Tag, Revision: item.Repository.Revision,
BuildSystem: item.Build.System, WorkspaceRef: item.Build.WorkspaceRef, EntryRef: item.Build.EntryRef, OutputArtifacts: domain.CopyStringSlice(item.OutputArtifacts),
Deployment: domain.RuntimeClientManagerDeployment{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: domain.CopyStringSlice(item.Deployment.Arguments), AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: domain.CopyStringSlice(item.Deployment.RequiredRunCapabilities)},
Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: domain.CopyStringSlice(item.Lifecycle.Actions), StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
Health: domain.RuntimeClientManagerHealth{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: domain.CopyStringSlice(item.Health.RequiredCapabilities)},
Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
}
for _, target := range item.SupportedTargets {
manager.SupportedTargets = append(manager.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
}
for _, config := range item.ConfigTemplates {
manager.ConfigTemplates = append(manager.ConfigTemplates, domain.RuntimeConfigTemplate{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
}
profiles.ClientManagers = append(profiles.ClientManagers, manager)
}
return profiles
}
func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePluginRuntimeProfilesBody {
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
body := GamePluginRuntimeProfilesBody{}
for _, item := range profiles.Discovery {
body.Discovery = append(body.Discovery, RuntimeDiscoveryProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: item.Platforms})
}
for _, item := range profiles.LifecycleProfiles {
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, ClientManagerRef: item.ClientManagerRef, Platforms: item.Platforms})
}
for _, item := range profiles.DependencyProbes {
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms})
}
for _, item := range profiles.InstallPlans {
plan := RuntimeInstallPlanBody{Key: item.Key, Title: item.Title, Platforms: item.Platforms}
for _, step := range item.Steps {
plan.Steps = append(plan.Steps, RuntimeInstallStepBody{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum})
}
body.InstallPlans = append(body.InstallPlans, plan)
}
for _, item := range profiles.LogSources {
body.LogSources = append(body.LogSources, RuntimeLogSourceBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, StreamKey: item.StreamKey, CursorKind: item.CursorKind, RetentionDays: item.RetentionDays})
}
for _, item := range profiles.TransportProfiles {
body.TransportProfiles = append(body.TransportProfiles, RuntimeTransportProfileBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Capabilities: item.Capabilities})
}
for _, item := range profiles.ClientManagers {
manager := RuntimeClientManagerProfileBody{
Key: item.Key, DisplayName: item.DisplayName, Version: item.Version,
Repository: RuntimeRepositoryBody{URL: item.RepositoryURL, RevisionPolicy: item.RevisionPolicy, Branch: item.Branch, Tag: item.Tag, Revision: item.Revision},
Build: RuntimeBuildBody{System: item.BuildSystem, WorkspaceRef: item.WorkspaceRef, EntryRef: item.EntryRef}, OutputArtifacts: item.OutputArtifacts,
Deployment: RuntimeClientManagerDeploymentBody{Mode: item.Deployment.Mode, ExecutableRef: item.Deployment.ExecutableRef, Arguments: item.Deployment.Arguments, AutoStart: item.Deployment.AutoStart, RequiredRunCapabilities: item.Deployment.RequiredRunCapabilities},
Lifecycle: RuntimeClientManagerLifecycleBody{Actions: item.Lifecycle.Actions, StartupTimeoutSeconds: item.Lifecycle.StartupTimeoutSeconds, StopTimeoutSeconds: item.Lifecycle.StopTimeoutSeconds},
Health: RuntimeClientManagerHealthBody{Mode: item.Health.Mode, IntervalSeconds: item.Health.IntervalSeconds, DegradedAfterSeconds: item.Health.DegradedAfterSeconds, OfflineAfterSeconds: item.Health.OfflineAfterSeconds, RequiredCapabilities: item.Health.RequiredCapabilities},
Compatibility: RuntimeClientManagerCompatibilityBody{MinimumVersion: item.Compatibility.MinimumVersion, MaximumVersion: item.Compatibility.MaximumVersion, AllowDowngrade: item.Compatibility.AllowDowngrade},
UpdatePolicy: RuntimeClientManagerUpdatePolicyBody{Strategy: item.UpdatePolicy.Strategy, RequireApproval: item.UpdatePolicy.RequireApproval, HealthConfirmationSeconds: item.UpdatePolicy.HealthConfirmationSeconds, RetainPrevious: item.UpdatePolicy.RetainPrevious},
}
for _, target := range item.SupportedTargets {
manager.SupportedTargets = append(manager.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
}
for _, config := range item.ConfigTemplates {
manager.ConfigTemplates = append(manager.ConfigTemplates, RuntimeConfigTemplateBody{Key: config.Key, TemplateRef: config.TemplateRef, OutputRef: config.OutputRef})
}
body.ClientManagers = append(body.ClientManagers, manager)
}
return body
}
+10 -6
View File
@@ -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),
}
}
+114
View File
@@ -0,0 +1,114 @@
package model
import (
"time"
"browser.local/platform/domain"
)
// ClientManagerInstallation is the durable desired/observed lifecycle aggregate for one server/profile.
type ClientManagerInstallation struct {
// ID is the stable installation identifier.
ID string `json:"id" db:"id"`
// ServerInstanceID scopes the installation to one authorized server.
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
// PluginID identifies the installed game plugin declaration.
PluginID string `json:"pluginId" db:"plugin_id"`
// ProfileKey identifies the plugin client-manager profile.
ProfileKey string `json:"profileKey" db:"profile_key"`
// RunEndpointID is the fenced machine executor assignment.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// TargetOS is the package operating-system target.
TargetOS string `json:"targetOs" db:"target_os"`
// TargetArch is the package architecture target.
TargetArch string `json:"targetArch" db:"target_arch"`
// Status is the durable lifecycle state.
Status domain.ClientManagerLifecycleStatus `json:"status" db:"status"`
// Phase is a bounded safe execution phase.
Phase string `json:"phase" db:"phase"`
DesiredVersion string `json:"desiredVersion" db:"desired_version"`
ActiveVersion string `json:"activeVersion" db:"active_version"`
PreviousVersion string `json:"previousVersion" db:"previous_version"`
DesiredRevision string `json:"desiredRevision" db:"desired_revision"`
ActiveRevision string `json:"activeRevision" db:"active_revision"`
PreviousRevision string `json:"previousRevision" db:"previous_revision"`
DesiredArtifactID string `json:"desiredArtifactId" db:"desired_artifact_id"`
ActiveArtifactID string `json:"activeArtifactId" db:"active_artifact_id"`
PreviousArtifactID string `json:"previousArtifactId" db:"previous_artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
CurrentJobID string `json:"currentJobId" db:"current_job_id"`
LastSuccessfulJobID string `json:"lastSuccessfulJobId" db:"last_successful_job_id"`
LastOperation domain.ClientManagerLifecycleOperation `json:"lastOperation" db:"last_operation"`
Health domain.ClientManagerHealthStatus `json:"health" db:"health"`
HealthReason string `json:"healthReason" db:"health_reason"`
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
Retryable bool `json:"retryable" db:"retryable"`
RequiresRedeploy bool `json:"requiresRedeploy" db:"requires_redeploy"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
InstalledAt time.Time `json:"installedAt" db:"installed_at"`
UninstalledAt time.Time `json:"uninstalledAt" db:"uninstalled_at"`
}
func (ClientManagerInstallation) TableName() string { return "client_manager_installations" }
// ClientManagerSession stores only the hash and fences for a short-lived component session.
type ClientManagerSession struct {
ID string `json:"id" db:"id"`
InstallationID string `json:"installationId" db:"installation_id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
ProfileKey string `json:"profileKey" db:"profile_key"`
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"`
DeploymentGeneration int `json:"deploymentGeneration" db:"deployment_generation"`
TokenHash string `json:"tokenHash" db:"token_hash"`
Capabilities []string `json:"capabilities" db:"capabilities"`
Status domain.ClientManagerSessionStatus `json:"status" db:"status"`
LastHeartbeatSequence uint64 `json:"lastHeartbeatSequence" db:"last_heartbeat_sequence"`
LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
RevokedAt time.Time `json:"revokedAt" db:"revoked_at"`
}
func (ClientManagerSession) TableName() string { return "client_manager_sessions" }
// ClientManagerRegistrationNonce is a bounded replay fence for one signed registration request.
type ClientManagerRegistrationNonce struct {
ID string `json:"id" db:"id"`
InstallationID string `json:"installationId" db:"installation_id"`
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
func (ClientManagerRegistrationNonce) TableName() string { return "client_manager_registration_nonces" }
func ClientManagerInstallationFromDomain(value domain.ClientManagerInstallation) ClientManagerInstallation {
return ClientManagerInstallation(value)
}
func (value ClientManagerInstallation) ToDomain() domain.ClientManagerInstallation {
return domain.ClientManagerInstallation(value)
}
func ClientManagerSessionFromDomain(value domain.ClientManagerSession) ClientManagerSession {
value = domain.CopyClientManagerSession(value)
return ClientManagerSession(value)
}
func (value ClientManagerSession) ToDomain() domain.ClientManagerSession {
return domain.CopyClientManagerSession(domain.ClientManagerSession(value))
}
func ClientManagerRegistrationNonceFromDomain(value domain.ClientManagerRegistrationNonce) ClientManagerRegistrationNonce {
return ClientManagerRegistrationNonce(value)
}
func (value ClientManagerRegistrationNonce) ToDomain() domain.ClientManagerRegistrationNonce {
return domain.ClientManagerRegistrationNonce(value)
}
+16
View File
@@ -10,6 +10,7 @@ type RuntimeBinding struct {
ID string `json:"id" db:"id"`
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),
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"time"
"browser.local/platform/domain"
)
type MetricSample struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
Online bool `json:"online" db:"online"`
PlayerCount *int `json:"playerCount,omitempty" db:"player_count"`
MaxPlayers *int `json:"maxPlayers,omitempty" db:"max_players"`
TPS *float64 `json:"tps,omitempty" db:"tps"`
LatencyMS *float64 `json:"latencyMs,omitempty" db:"latency_ms"`
CPUPercent *float64 `json:"cpuPercent,omitempty" db:"cpu_percent"`
MemoryPercent *float64 `json:"memoryPercent,omitempty" db:"memory_percent"`
DiskPercent *float64 `json:"diskPercent,omitempty" db:"disk_percent"`
Source string `json:"source" db:"source"`
CollectedAt time.Time `json:"collectedAt" db:"collected_at"`
}
func (MetricSample) TableName() string { return "metric_samples" }
func MetricSampleFromDomain(sample domain.MetricSample) MetricSample {
return MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, RunEndpointID: sample.RunEndpointID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
func (sample MetricSample) ToDomain() domain.MetricSample {
return domain.MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, RunEndpointID: sample.RunEndpointID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
}
type BackupRecord struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"`
SizeBytes int64 `json:"sizeBytes" db:"size_bytes"`
State domain.BackupState `json:"state" db:"state"`
RecoveryStatus string `json:"recoveryStatus" db:"recovery_status"`
RetentionUntil time.Time `json:"retentionUntil,omitempty" db:"retention_until"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (BackupRecord) TableName() string { return "backup_records" }
func BackupRecordFromDomain(record domain.BackupRecord) BackupRecord {
return BackupRecord{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
func (record BackupRecord) ToDomain() domain.BackupRecord {
return domain.BackupRecord{ID: record.ID, ServerInstanceID: record.ServerInstanceID, ArtifactID: record.ArtifactID, Checksum: record.Checksum, SizeBytes: record.SizeBytes, State: record.State, RecoveryStatus: record.RecoveryStatus, RetentionUntil: record.RetentionUntil, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt}
}
+247 -42
View File
@@ -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,
}
}
+19 -11
View File
@@ -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) {
+1 -1
View File
@@ -32,7 +32,7 @@ AI invocation responses must be bounded and must not include raw provider creden
- `AIProviderCreateRequest`: create provider metadata with `apiKeyRef`, never raw key material.
- `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.
+30
View File
@@ -0,0 +1,30 @@
# Authentication and Service Identity Contracts
## Platform bearer sessions
- Login and first-user registration issue a random bearer token with an eight-hour expiry.
- Strict production HTTP routes deliver the session through an `HttpOnly`, `SameSite=Strict` cookie and omit it from JSON. `X-Auth-Token-Response: bearer` is an explicit CLI compatibility mode.
- Durable stores keep only `AuthSessionRecord.tokenHash`, owner, generation, status, and lifecycle timestamps.
- Logout and rotation set `status=revoked` and `revokedAt`; rotation issues a distinct generation.
- Missing, unknown, expired, revoked, or disabled-user sessions return a safe `401 unauthorized` error.
## Authorization roles
- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, audit, and global internal resource creation.
- server owner: server membership, runtime binding changes, destructive/archive operations, and all visible server actions.
- server administrator: non-owner operational access to assigned server resources, but no owner-only membership or secret/key rotation.
- Run service: control/job/log/artifact channels for its current endpoint session; it cannot use browser bearer authority.
Job, log, artifact, runtime-binding, distribution, and plugin-bridge services resolve the target server and repeat ownership checks independently from the HTTP router.
## Run signed envelope
Component-authenticated Run hello responses advertise `signed-envelope.v1.required`. Subsequent HTTP channel calls carry `X-Run-Endpoint`, `X-Run-Timestamp` (Unix seconds), `X-Run-Nonce`, and `X-Run-Signature` (hex HMAC-SHA256).
The canonical payload is `METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + SHA256(BODY)`. The current Run session token is the HMAC key. The platform rejects endpoint mismatch, invalid signatures, timestamps outside a five-minute window, expired/revoked sessions, and replayed nonces. Legacy non-component local test sessions remain an explicit compatibility path and advertise the envelope as optional.
## Secret boundary
Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and controlled `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only.
Component-key ciphertext uses an injectable AES-GCM envelope derived from `PLATFORM_SECRET_ENVELOPE_KEY`; the built-in key is a disposable-development fallback only. This boundary is not a production KMS/vault. External key wrapping, KMS/HSM integration, multi-node replay coordination, envelope-key migration, and secret-value rotation remain deferred risks.
@@ -0,0 +1,19 @@
# Dependency And Run Update Contracts
Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and audit projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies.
## Dependency flow
1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest.
2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale approval is denied and audited.
3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal.
4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`.
## Self-update flow
1. Platform accepts only an available Run distribution owned by the same server and matching the registered endpoint OS/architecture/checksum.
2. Run retrieves private metadata through `update-input`, reads 1 MiB-or-smaller ranges through `update-chunk`, persists offsets, verifies the final artifact checksum, rejects traversal/links/devices/unexpected entries, and stages exactly the expected executable without replacing configuration.
3. The terminal staged result moves the safe phase to `restart-requested`. The local journal persists the activation manifest before helper launch. The helper backs up/replaces atomically, starts the new binary with helper environment removed, waits for health, and rolls back on timeout or identity failure.
4. The new Run reports success or rollback through signed `update-health` only after registration and job reconciliation. Platform then projects `succeeded` or `rolled-back`; a hello-only outcome is never treated as health confirmation.
Control heartbeat, job ack/result/cancel/reconcile, durable logs, and artifact upload use independent loops and deadlines. This contract does not include production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, production scaling/alerts, external mirrors/storage, or real AI-provider integration.
@@ -0,0 +1,58 @@
package repo
import (
"path/filepath"
"testing"
"time"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleRepositoriesPersistAndFilter(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
stamp := time.Date(2026, 7, 18, 3, 0, 0, 0, time.UTC)
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", ActiveVersion: "1.0.0", ActiveArtifactID: "artifact-1", KeyGeneration: 2, DeploymentGeneration: 3, Health: domain.ClientManagerHealthHealthy, LastSeenAt: stamp, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.ClientManagerInstallations().Create(installation); err != nil {
t.Fatalf("persist installation: %v", err)
}
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, RunEndpointID: installation.RunEndpointID, ArtifactID: installation.ActiveArtifactID, KeyGeneration: 2, DeploymentGeneration: 3, TokenHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Capabilities: []string{"component.register", "component.heartbeat"}, Status: domain.ClientManagerSessionActive, LastSeenAt: stamp, ExpiresAt: stamp.Add(15 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
if err := store.ClientManagerSessions().Create(session); err != nil {
t.Fatalf("persist session: %v", err)
}
nonce := domain.ClientManagerRegistrationNonce{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", InstallationID: installation.ID, CreatedAt: stamp, ExpiresAt: stamp.Add(5 * time.Minute)}
if err := store.ClientManagerNonces().Create(nonce); err != nil {
t.Fatalf("persist nonce: %v", err)
}
restarted, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
installations, err := restarted.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", Status: domain.ClientManagerLifecycleOnline})
if err != nil || len(installations) != 1 || installations[0].DeploymentGeneration != 3 {
t.Fatalf("unexpected persisted installations: items=%+v err=%v", installations, err)
}
sessions, err := restarted.ClientManagerSessions().List(domain.ClientManagerSessionFilter{InstallationID: installation.ID, Status: domain.ClientManagerSessionActive})
if err != nil || len(sessions) != 1 {
t.Fatalf("unexpected persisted sessions: items=%+v err=%v", sessions, err)
}
sessions[0].Capabilities[0] = "mutated"
stored, _ := restarted.ClientManagerSessions().Get(session.ID)
if stored.Capabilities[0] != "component.register" {
t.Fatalf("repository returned shared capability storage: %+v", stored)
}
expired, err := restarted.ClientManagerNonces().List(domain.ClientManagerNonceFilter{ExpiresBefore: stamp.Add(6 * time.Minute)})
if err != nil || len(expired) != 1 {
t.Fatalf("unexpected nonce expiry filter: items=%+v err=%v", expired, err)
}
if err := restarted.ClientManagerNonces().Delete(nonce.ID); err != nil {
t.Fatalf("delete expired nonce: %v", err)
}
if _, err := restarted.ClientManagerNonces().Get(nonce.ID); err != ErrNotFound {
t.Fatalf("expected deleted nonce, got %v", err)
}
}
+124 -18
View File
@@ -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
+93 -9
View File
@@ -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)
}
+168 -8
View File
@@ -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)
}
+133 -8
View File
@@ -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) {
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
type ArtifactBodyStore interface {
SaveTransfer(domain.ArtifactTransferSession) error
LoadTransfers() ([]domain.ArtifactTransferSession, error)
PutPayload(string, []byte) error
GetPayload(string) ([]byte, error)
}
type MemoryArtifactBodyStore struct {
mu sync.Mutex
transfers map[string]domain.ArtifactTransferSession
payloads map[string][]byte
}
func NewMemoryArtifactBodyStore() *MemoryArtifactBodyStore {
return &MemoryArtifactBodyStore{transfers: map[string]domain.ArtifactTransferSession{}, payloads: map[string][]byte{}}
}
func (store *MemoryArtifactBodyStore) SaveTransfer(session domain.ArtifactTransferSession) error {
store.mu.Lock()
defer store.mu.Unlock()
store.transfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return nil
}
func (store *MemoryArtifactBodyStore) LoadTransfers() ([]domain.ArtifactTransferSession, error) {
store.mu.Lock()
defer store.mu.Unlock()
ids := make([]string, 0, len(store.transfers))
for id := range store.transfers {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]domain.ArtifactTransferSession, 0, len(ids))
for _, id := range ids {
out = append(out, domain.CopyArtifactTransferSession(store.transfers[id]))
}
return out, nil
}
func (store *MemoryArtifactBodyStore) PutPayload(artifactID string, payload []byte) error {
store.mu.Lock()
defer store.mu.Unlock()
store.payloads[artifactID] = domain.CopyBytes(payload)
return nil
}
func (store *MemoryArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
store.mu.Lock()
defer store.mu.Unlock()
payload, exists := store.payloads[artifactID]
if !exists {
return nil, repo.ErrNotFound
}
return domain.CopyBytes(payload), nil
}
type FileArtifactBodyStore struct {
mu sync.Mutex
rootDir string
}
func NewFileArtifactBodyStore(rootDir string) (*FileArtifactBodyStore, error) {
rootDir = strings.TrimSpace(rootDir)
if rootDir == "" {
return nil, fmt.Errorf("artifact directory is required")
}
for _, path := range []string{rootDir, filepath.Join(rootDir, "transfers"), filepath.Join(rootDir, "payloads")} {
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, fmt.Errorf("create artifact body directory: %w", err)
}
}
return &FileArtifactBodyStore{rootDir: rootDir}, nil
}
func (store *FileArtifactBodyStore) SaveTransfer(session domain.ArtifactTransferSession) error {
store.mu.Lock()
defer store.mu.Unlock()
dir := store.transferDir(session.TransferID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create artifact transfer directory: %w", err)
}
manifest := domain.CopyArtifactTransferSession(session)
for index, record := range manifest.ReceivedChunks {
payload := domain.CopyBytes(record.Payload)
if len(payload) != record.SizeBytes || validator.BytesChecksum(payload) != record.Checksum {
return validationError("artifact chunk does not match durable manifest")
}
if err := writeAtomicFile(filepath.Join(dir, fmt.Sprintf("chunk-%08d.bin", index)), payload, 0o600); err != nil {
return err
}
record.Payload = nil
manifest.ReceivedChunks[index] = record
}
body, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("encode artifact transfer manifest: %w", err)
}
return writeAtomicFile(filepath.Join(dir, "manifest.json"), body, 0o600)
}
func (store *FileArtifactBodyStore) LoadTransfers() ([]domain.ArtifactTransferSession, error) {
store.mu.Lock()
defer store.mu.Unlock()
entries, err := os.ReadDir(filepath.Join(store.rootDir, "transfers"))
if err != nil {
return nil, fmt.Errorf("read artifact transfer directory: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
out := make([]domain.ArtifactTransferSession, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(store.rootDir, "transfers", entry.Name())
body, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("read artifact transfer manifest: %w", err)
}
var session domain.ArtifactTransferSession
if err := json.Unmarshal(body, &session); err != nil {
return nil, fmt.Errorf("decode artifact transfer manifest: %w", err)
}
if session.TransferID == "" || store.transferDir(session.TransferID) != dir {
return nil, fmt.Errorf("artifact transfer manifest identity mismatch")
}
for index, record := range session.ReceivedChunks {
payload, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("chunk-%08d.bin", index)))
if err != nil {
return nil, fmt.Errorf("read artifact transfer chunk: %w", err)
}
if len(payload) != record.SizeBytes || validator.BytesChecksum(payload) != record.Checksum {
return nil, validationError("durable artifact chunk checksum mismatch")
}
record.Payload = payload
session.ReceivedChunks[index] = record
}
out = append(out, domain.CopyArtifactTransferSession(session))
}
return out, nil
}
func (store *FileArtifactBodyStore) PutPayload(artifactID string, payload []byte) error {
store.mu.Lock()
defer store.mu.Unlock()
return writeAtomicFile(store.payloadPath(artifactID), payload, 0o600)
}
func (store *FileArtifactBodyStore) GetPayload(artifactID string) ([]byte, error) {
store.mu.Lock()
defer store.mu.Unlock()
payload, err := os.ReadFile(store.payloadPath(artifactID))
if errors.Is(err, os.ErrNotExist) {
return nil, repo.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("read artifact payload: %w", err)
}
return payload, nil
}
func (store *FileArtifactBodyStore) transferDir(transferID string) string {
return filepath.Join(store.rootDir, "transfers", stableStorageKey(transferID))
}
func (store *FileArtifactBodyStore) payloadPath(artifactID string) string {
return filepath.Join(store.rootDir, "payloads", stableStorageKey(artifactID)+".bin")
}
func stableStorageKey(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func writeAtomicFile(path string, payload []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create durable body directory: %w", err)
}
tmp := path + ".tmp"
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return fmt.Errorf("open durable body temporary file: %w", err)
}
if _, err := file.Write(payload); err != nil {
_ = file.Close()
_ = os.Remove(tmp)
return fmt.Errorf("write durable body: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
_ = os.Remove(tmp)
return fmt.Errorf("sync durable body: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close durable body: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace durable body: %w", err)
}
return nil
}
+13 -1
View File
@@ -1,6 +1,7 @@
package service
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
}
+13
View File
@@ -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
}
@@ -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) {
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const defaultAuthSessionTTL = 8 * time.Hour
func (svc *CoreService) issueAuthSession(user domain.User, message string) (domain.AuthSession, error) {
token, err := randomToken()
if err != nil {
return domain.AuthSession{}, err
}
hash := tokenHash(token)
stamp := svc.now()
generation := 1
existing, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{UserID: user.ID})
if err != nil {
return domain.AuthSession{}, err
}
for _, session := range existing {
if session.Generation >= generation {
generation = session.Generation + 1
}
}
record := domain.AuthSessionRecord{
ID: "auth-session-" + hash[:24],
UserID: user.ID,
TokenHash: hash,
Status: domain.AuthSessionStatusActive,
Generation: generation,
IssuedAt: stamp,
ExpiresAt: stamp.Add(defaultAuthSessionTTL),
LastSeenAt: stamp,
}
if err := validator.ValidateAuthSessionRecord(record); err != nil {
return domain.AuthSession{}, err
}
if err := svc.store.AuthSessions().Create(record); err != nil {
return domain.AuthSession{}, err
}
svc.authMu.Lock()
svc.authSessions[token] = user.ID
svc.authMu.Unlock()
return domain.AuthSession{
SessionID: token,
User: domain.CopyUser(user),
Status: "authenticated",
Message: message,
ExpiresAt: record.ExpiresAt,
}, nil
}
func (svc *CoreService) authenticatedSession(token string) (domain.AuthSessionRecord, error) {
token = strings.TrimSpace(token)
if token == "" {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
hash := tokenHash(token)
sessions, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{TokenHash: hash})
if err != nil {
return domain.AuthSessionRecord{}, err
}
if len(sessions) != 1 || subtle.ConstantTimeCompare([]byte(sessions[0].TokenHash), []byte(hash)) != 1 {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
session := sessions[0]
stamp := svc.now()
if session.Status != domain.AuthSessionStatusActive || !session.RevokedAt.IsZero() || !stamp.Before(session.ExpiresAt) {
if session.Status == domain.AuthSessionStatusActive && !stamp.Before(session.ExpiresAt) {
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
_ = svc.store.AuthSessions().Update(session)
}
return domain.AuthSessionRecord{}, ErrUnauthorized
}
user, err := svc.store.Users().Get(session.UserID)
if err != nil {
if errors.Is(err, repo.ErrNotFound) {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
return domain.AuthSessionRecord{}, err
}
if user.Status != domain.UserStatusActive {
return domain.AuthSessionRecord{}, ErrUnauthorized
}
if session.LastSeenAt.IsZero() || stamp.Sub(session.LastSeenAt) >= time.Minute {
session.LastSeenAt = stamp
if err := svc.store.AuthSessions().Update(session); err != nil {
return domain.AuthSessionRecord{}, err
}
}
return domain.CopyAuthSessionRecord(session), nil
}
func (svc *CoreService) revokeAuthSession(token string) error {
session, err := svc.authenticatedSession(token)
if err != nil {
return err
}
stamp := svc.now()
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
session.LastSeenAt = stamp
if err := validator.ValidateAuthSessionRecord(session); err != nil {
return err
}
if err := svc.store.AuthSessions().Update(session); err != nil {
return err
}
svc.authMu.Lock()
delete(svc.authSessions, token)
svc.authMu.Unlock()
return nil
}
func (svc *CoreService) RotateUserSession(token string) (domain.AuthSession, error) {
session, err := svc.authenticatedSession(token)
if err != nil {
return domain.AuthSession{}, err
}
user, err := svc.store.Users().Get(session.UserID)
if err != nil {
return domain.AuthSession{}, err
}
if err := svc.revokeAuthSession(token); err != nil {
return domain.AuthSession{}, err
}
return svc.issueAuthSession(user, "会话已安全轮换")
}
func (svc *CoreService) revokeUserSessions(userID string) error {
sessions, err := svc.store.AuthSessions().List(domain.AuthSessionFilter{UserID: userID, Status: domain.AuthSessionStatusActive})
if err != nil {
return err
}
stamp := svc.now()
for _, session := range sessions {
session.Status = domain.AuthSessionStatusRevoked
session.RevokedAt = stamp
session.LastSeenAt = stamp
if err := validator.ValidateAuthSessionRecord(session); err != nil {
return err
}
if err := svc.store.AuthSessions().Update(session); err != nil {
return err
}
}
return nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+194
View File
@@ -0,0 +1,194 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestAuthSessionPersistsWithoutRawTokenAndRotates(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
user, err := svc.CreateUser(domain.User{
ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test",
Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
if session.SessionID == "" || !session.ExpiresAt.Equal(now.Add(defaultAuthSessionTTL)) {
t.Fatalf("unexpected bounded session: %+v", session)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), session.SessionID) || strings.Contains(string(payload), "secret-password") {
t.Fatalf("snapshot contains raw session or password literal: %s", payload)
}
if !strings.Contains(string(payload), tokenHash(session.SessionID)) {
t.Fatalf("snapshot does not contain the expected one-way session verifier")
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
if current, err := reloaded.GetCurrentUser(session.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("restored session was not accepted: current=%+v err=%v", current, err)
}
rotated, err := reloaded.RotateUserSession(session.SessionID)
if err != nil {
t.Fatalf("rotate session: %v", err)
}
if rotated.SessionID == "" || rotated.SessionID == session.SessionID {
t.Fatalf("rotation did not issue a distinct token")
}
if _, err := reloaded.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("revoked prior token should be unauthorized, got %v", err)
}
if current, err := reloaded.GetCurrentUser(rotated.SessionID); err != nil || current.ID != user.ID {
t.Fatalf("rotated token was not accepted: current=%+v err=%v", current, err)
}
}
func TestAuthSessionExpiryIsDurablyRevoked(t *testing.T) {
store := repo.NewMemoryStore()
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
if _, err := svc.CreateUser(domain.User{ID: "user-expiry", DisplayName: "Expiry", Email: "expiry@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: "expiry@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
now = now.Add(defaultAuthSessionTTL + time.Second)
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expired session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{TokenHash: tokenHash(session.SessionID)})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("expired session was not durably revoked: records=%+v err=%v", records, err)
}
}
func TestDisablingUserRevokesActiveSessions(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
user, err := svc.CreateUser(domain.User{ID: "user-disabled", DisplayName: "Disabled", Email: "disabled@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"})
if err != nil {
t.Fatalf("create user: %v", err)
}
session, err := svc.LoginUser(domain.UserLogin{Account: user.Email, Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
user.Status = domain.UserStatusDisabled
if _, err := svc.UpdateUser(user.ID, user); err != nil {
t.Fatalf("disable user: %v", err)
}
if _, err := svc.GetCurrentUser(session.SessionID); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("disabled user session should be unauthorized, got %v", err)
}
records, err := store.AuthSessions().List(domain.AuthSessionFilter{UserID: user.ID})
if err != nil || len(records) != 1 || records[0].Status != domain.AuthSessionStatusRevoked || records[0].RevokedAt.IsZero() {
t.Fatalf("disabled user sessions were not revoked: records=%+v err=%v", records, err)
}
}
func TestRunSessionPersistsAndSignedEnvelopeRejectsReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("create store: %v", err)
}
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
svc := newCoreService(store, func() time.Time { return now })
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register run: %v", err)
}
record, err := store.RunControlSessions().Get("run-local")
if err != nil {
t.Fatalf("get run session: %v", err)
}
record.RequireSignedRequests = true
if err := store.RunControlSessions().Update(record); err != nil {
t.Fatalf("require signed requests: %v", err)
}
delete(svc.runSessions, "run-local")
request := domain.RunRequestSignature{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Method: "POST",
Path: "/api/v1/run/jobs/claim",
Timestamp: strconv.FormatInt(now.Unix(), 10),
Nonce: "nonce-1",
BodyHash: strings.Repeat("a", 64),
}
request.Signature = signRunRequest(request)
if err := svc.AuthorizeRunRequestSignature(request); err != nil {
t.Fatalf("authorize signed request: %v", err)
}
if err := svc.AuthorizeRunRequestSignature(request); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("replayed nonce should be unauthorized, got %v", err)
}
stale := request
stale.Nonce = "nonce-2"
stale.Timestamp = strconv.FormatInt(now.Add(-maxRunRequestClockSkew-time.Second).Unix(), 10)
stale.Signature = signRunRequest(stale)
if err := svc.AuthorizeRunRequestSignature(stale); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("stale signature should be unauthorized, got %v", err)
}
reloadedStore, err := repo.NewFileStore(path)
if err != nil {
t.Fatalf("reload store: %v", err)
}
reloaded := newCoreService(reloadedStore, func() time.Time { return now.Add(time.Minute) })
result, err := reloaded.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1",
Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-jobs",
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil || !result.Accepted {
t.Fatalf("reloaded hashed Run session was not accepted: result=%+v err=%v", result, err)
}
payload, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
if strings.Contains(string(payload), hello.SessionToken) || strings.Contains(string(payload), "registration-token") {
t.Fatalf("snapshot contains raw Run credential: %s", payload)
}
}
func signRunRequest(request domain.RunRequestSignature) string {
canonical := strings.Join([]string{request.Method, request.Path, request.Timestamp, request.Nonce, request.BodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(request.SessionToken))
_, _ = mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestClientManagerLifecycleBuildDeployRegisterHealthUpdateRollbackAndUninstall(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
baseTime := svc.now()
svc.now = func() time.Time { return baseTime }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-build-v1")
view, err := svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleAvailable {
t.Fatalf("expected available build projection, view=%+v err=%v", view, err)
}
view, err = svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleDeploying || view.Job.State != domain.JobStateQueued {
t.Fatalf("queue deployment: view=%+v err=%v", view, err)
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-deploy-v1"}); err != nil {
t.Fatalf("idempotent deployment: %v", err)
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
input, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil || input.ArtifactID != distribution.ArtifactID || input.KeyGeneration != distribution.KeyGeneration || input.DeploymentGeneration != view.Installation.DeploymentGeneration || strings.Contains(strings.Join(input.Arguments, " "), "/Users/") {
t.Fatalf("get fenced deployment input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadClientManagerLifecycleChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 7})
if err != nil || len(chunk.Payload) == 0 || chunk.ArtifactID != distribution.ArtifactID {
t.Fatalf("read deployment chunk: chunk=%+v err=%v", chunk, err)
}
if _, err := svc.GetClientManagerLifecycleInput(domain.ClientManagerLifecycleInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1}); err == nil {
t.Fatal("expected stale attempt to be rejected")
}
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleRegistering || view.Installation.ActiveArtifactID != distribution.ArtifactID {
t.Fatalf("expected deployed registration state, got %+v", view.Installation)
}
componentKey := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
registerRequest := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, baseTime)
registerRequest.Signature = clientManagerRegistrationSignature(componentKey, registerRequest)
registration, err := svc.RegisterClientManager(registerRequest)
if err != nil || !registration.Accepted || registration.SessionToken == "" {
t.Fatalf("register client manager: result=%+v err=%v", registration, err)
}
if _, err := svc.RegisterClientManager(registerRequest); err == nil {
t.Fatal("expected registration nonce replay rejection")
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: runSession, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("Run control session must not authenticate as a Client Manager session")
}
heartbeat, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", Capabilities: registerRequest.Capabilities, SentAt: baseTime})
if err != nil || heartbeat.Status != domain.ClientManagerLifecycleOnline {
t.Fatalf("accept heartbeat: result=%+v err=%v", heartbeat, err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: registerRequest.Capabilities, SentAt: baseTime}); err == nil {
t.Fatal("expected replayed heartbeat sequence rejection")
}
baseTime = baseTime.Add(50 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile degraded health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleDegraded {
t.Fatalf("expected degraded heartbeat timeout, got %+v", view.Installation)
}
baseTime = baseTime.Add(80 * time.Second)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("reconcile offline health: %v", err)
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleOffline {
t.Fatalf("expected offline heartbeat timeout, got %+v", view.Installation)
}
updatedDistribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.1.0", "lifecycle-build-v2")
view, err = svc.UpdateClientManagerForSession(ownerSession, domain.ClientManagerUpdateRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: updatedDistribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Approved: true, IdempotencyKey: "lifecycle-update-v2"})
if err != nil || view.Installation.Status != domain.ClientManagerLifecycleUpdating {
t.Fatalf("queue staged update: view=%+v err=%v", view, err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateFailed, "client-manager.rollback.restored", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || !strings.Contains(view.Installation.Phase, "previous deployment restored") || !view.Installation.Retryable {
t.Fatalf("expected failed update to retain previous active slot, got %+v", view.Installation)
}
view, err = svc.RetryClientManagerLifecycleForSession(ownerSession, domain.ClientManagerRetryRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-update-v2-retry"})
if err != nil {
t.Fatalf("retry staged update: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUpdate)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.updated", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != updatedDistribution.ArtifactID || view.Installation.PreviousArtifactID != distribution.ArtifactID || view.Installation.Status != domain.ClientManagerLifecycleRegistering {
t.Fatalf("expected successful update slot commit, got %+v", view.Installation)
}
view, err = svc.ControlClientManagerForSession(ownerSession, domain.ClientManagerControlRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", Operation: domain.ClientManagerOperationRollback, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, IdempotencyKey: "lifecycle-rollback-v1"})
if err != nil {
t.Fatalf("queue explicit rollback: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerRollback)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.rolled-back", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.ActiveArtifactID != distribution.ArtifactID || view.Installation.PreviousArtifactID != updatedDistribution.ArtifactID {
t.Fatalf("expected rollback slot swap, got %+v", view.Installation)
}
view, err = svc.UninstallClientManagerForSession(ownerSession, domain.ClientManagerUninstallRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration, Confirmed: true, IdempotencyKey: "lifecycle-uninstall"})
if err != nil {
t.Fatalf("queue uninstall: %v", err)
}
runSession = registerClientManagerRun(t, svc)
claim = claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerUninstall)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.uninstalled", "stopped")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if view.Installation.Status != domain.ClientManagerLifecycleUninstalled || view.Installation.ActiveArtifactID != "" {
t.Fatalf("expected durable uninstalled history, got %+v", view.Installation)
}
if _, err := svc.store.ClientManagerDistributions().Get(distribution.ID); err != nil {
t.Fatalf("uninstall must retain distribution history: %v", err)
}
}
func TestClientManagerLifecycleRejectsCrossScopeStaleAndRevokedIdentity(t *testing.T) {
svc, ownerSession, instance := newDistributionTestFixture(t)
now := svc.now()
svc.now = func() time.Time { return now }
distribution := buildLifecycleDistribution(t, svc, ownerSession, instance, "1.0.0", "lifecycle-scope-build")
view, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, IdempotencyKey: "lifecycle-scope-deploy"})
if err != nil {
t.Fatalf("queue deploy: %v", err)
}
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "other-owner", DisplayName: "Other", Email: "other-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetClientManagerLifecycleForSession(otherSession, instance.ID, "scum-client-manager"); err == nil {
t.Fatal("expected cross-owner lifecycle read denial")
}
if _, err := svc.DeployClientManagerForSession(ownerSession, domain.ClientManagerDeployRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", DistributionID: distribution.ID, ExpectedDeploymentGeneration: view.Installation.DeploymentGeneration + 1, IdempotencyKey: "lifecycle-stale-deploy"}); err == nil {
t.Fatal("expected stale deployment generation denial")
}
runSession := registerClientManagerRun(t, svc)
claim := claimClientManagerJob(t, svc, runSession, domain.JobCapabilityClientManagerDeploy)
completeClientManagerJob(t, svc, runSession, claim, domain.JobStateSucceeded, "client-manager.deployed", "running")
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
plain := currentClientManagerPlainKey(t, svc, instance.ID, "scum-client-manager")
request := lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.ArtifactID = "cross-server-artifact"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected cross-artifact registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.KeyGeneration++
request.Nonce = "nonce-stale-key-generation"
request.Signature = clientManagerRegistrationSignature(plain, request)
if _, err := svc.RegisterClientManager(request); err == nil {
t.Fatal("expected stale key generation registration rejection")
}
request = lifecycleRegisterRequest(view.Installation, []string{"component.register", "component.heartbeat", "component.health"}, now)
request.Nonce = "nonce-valid-component-identity"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err := svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register valid component: %v", err)
}
now = now.Add(16 * time.Minute)
if err := svc.ReconcileClientManagerLifecycle(); err != nil {
t.Fatalf("expire component session: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected expired component session rejection")
}
request.Timestamp = now
request.Nonce = "nonce-replacement-after-expiry"
request.Signature = clientManagerRegistrationSignature(plain, request)
registration, err = svc.RegisterClientManager(request)
if err != nil {
t.Fatalf("register replacement component session: %v", err)
}
if _, err := svc.ResetComponentKeyForSession(ownerSession, domain.ComponentKeyResetRequest{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentClientManager, ComponentKey: "scum-client-manager"}); err != nil {
t.Fatalf("reset component key: %v", err)
}
if _, err := svc.AcceptClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: view.Installation.ID, SessionToken: registration.SessionToken, Sequence: 1, Health: domain.ClientManagerHealthHealthy, Capabilities: request.Capabilities, SentAt: now}); err == nil {
t.Fatal("expected reset to revoke component session")
}
view, _ = svc.GetClientManagerLifecycleForSession(ownerSession, instance.ID, "scum-client-manager")
if !view.Installation.RequiresRedeploy || view.Installation.Status != domain.ClientManagerLifecycleFailed {
t.Fatalf("expected key reset recovery projection, got %+v", view.Installation)
}
for _, forbidden := range []string{plain, registration.SessionToken, "secret://", "/Users/", "tcp://"} {
payload := strings.Join([]string{view.Installation.Phase, view.Installation.HealthReason}, " ")
if strings.Contains(payload, forbidden) {
t.Fatalf("safe lifecycle view leaked %q: %s", forbidden, payload)
}
}
}
func buildLifecycleDistribution(t *testing.T, svc *CoreService, session string, instance domain.ServerInstance, version, idempotency string) domain.ClientManagerDistribution {
t.Helper()
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get lifecycle plugin: %v", err)
}
for i := range plugin.RuntimeProfiles.ClientManagers {
if plugin.RuntimeProfiles.ClientManagers[i].Key == "scum-client-manager" {
plugin.RuntimeProfiles.ClientManagers[i].Version = version
}
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle version: %v", err)
}
distribution, err := svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ServerInstanceID: instance.ID, ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: idempotency})
if err != nil {
t.Fatalf("generate lifecycle distribution: %v", err)
}
return completeClientDistributionBuild(t, svc, distribution, []byte("client-manager-package-"+version))
}
func registerClientManagerRun(t *testing.T, svc *CoreService) string {
t.Helper()
hello := validRunControlHello()
hello.Platform = "linux"
hello.Architecture = "amd64"
hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall)
result, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register lifecycle Run: %v", err)
}
return result.SessionToken
}
func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capability string) domain.RunJobClaimResult {
t.Helper()
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.Capability != capability {
t.Fatalf("claim %s job: claim=%+v err=%v", capability, claim, err)
}
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "typed lifecycle work started"}); err != nil {
t.Fatalf("ack lifecycle job: %v", err)
}
return claim
}
func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) {
t.Helper()
_, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, AuditSummary: "bounded lifecycle result"}})
if err != nil {
t.Fatalf("complete lifecycle job: %v", err)
}
}
func currentClientManagerPlainKey(t *testing.T, svc *CoreService, serverID, profileKey string) string {
t.Helper()
key, err := svc.activeComponentKey(serverID, domain.DistributionComponentClientManager, profileKey)
if err != nil {
t.Fatalf("get active component key: %v", err)
}
plain, err := svc.decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt component key: %v", err)
}
return plain
}
func lifecycleRegisterRequest(installation domain.ClientManagerInstallation, capabilities []string, stamp time.Time) domain.ClientManagerRegisterRequest {
return domain.ClientManagerRegisterRequest{InstallationID: installation.ID, ServerInstanceID: installation.ServerInstanceID, ProfileKey: installation.ProfileKey, ArtifactID: installation.ActiveArtifactID, Version: installation.ActiveVersion, SourceRevision: installation.ActiveRevision, TargetOS: installation.TargetOS, TargetArch: installation.TargetArch, KeyGeneration: installation.KeyGeneration, DeploymentGeneration: installation.DeploymentGeneration, Capabilities: capabilities, Timestamp: stamp, Nonce: "nonce-client-manager-registration"}
}
+152 -10
View File
@@ -1,8 +1,14 @@
package service
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
}
+615
View File
@@ -0,0 +1,615 @@
package service
import (
"encoding/json"
"errors"
"net/url"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const runUpdateChunkSize = 1024 * 1024
type dependencyResolution struct {
instance domain.ServerInstance
plugin domain.GamePlugin
binding domain.RuntimeBinding
endpoint domain.RunEndpoint
}
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.DependencyCatalog{}, err
}
resolution, err := svc.resolveDependencyContext(serverInstanceID)
if err != nil {
return domain.DependencyCatalog{}, err
}
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.DependencyCatalog{}, err
}
statusByProbe := map[string]domain.DependencyStatus{}
for _, status := range statuses {
statusByProbe[status.ProbeKey] = status
}
plans := make([]domain.DependencyPlanView, 0, len(resolution.plugin.RuntimeProfiles.InstallPlans))
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
if !runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) {
continue
}
steps := make([]domain.DependencyPlanStepView, len(plan.Steps))
for i, step := range plan.Steps {
host := ""
if parsed, parseErr := url.Parse(step.DownloadRef); parseErr == nil {
host = parsed.Hostname()
}
steps[i] = domain.DependencyPlanStepView{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadHost: host}
}
var planProbe domain.RuntimeDependencyProbe
for _, candidate := range resolution.plugin.RuntimeProfiles.DependencyProbes {
if runtimePlatformsContain(candidate.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, candidate) {
planProbe = candidate
break
}
}
plans = append(plans, domain.DependencyPlanView{Key: plan.Key, Title: plan.Title, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Digest: dependencyPlanDigest(resolution, planProbe, plan), Steps: steps})
}
sort.Slice(plans, func(i, j int) bool { return plans[i].Key < plans[j].Key })
probes := make([]domain.DependencyProbeView, 0, len(resolution.plugin.RuntimeProfiles.DependencyProbes))
for _, probe := range resolution.plugin.RuntimeProfiles.DependencyProbes {
if !runtimePlatformsContain(probe.Platforms, resolution.endpoint.Platform) {
continue
}
status := statusByProbe[probe.Key]
planKey := ""
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
if runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, probe) {
planKey = plan.Key
break
}
}
state := status.State
if state == "" {
state = domain.DependencyStateUnknown
}
probes = append(probes, domain.DependencyProbeView{Key: probe.Key, Kind: probe.Kind, Required: probe.Required, MinimumVersion: probe.MinimumVersion, State: state, Evidence: status.Evidence, InstallPlanKey: planKey})
}
sort.Slice(probes, func(i, j int) bool { return probes[i].Key < probes[j].Key })
return domain.CopyDependencyCatalog(domain.DependencyCatalog{ServerInstanceID: resolution.instance.ID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Probes: probes, Plans: plans, UpdatedAt: svc.now()}), nil
}
func (svc *CoreService) ListRunUpdateJobsForSession(sessionID, serverInstanceID string) ([]domain.RunUpdateJob, error) {
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return nil, err
}
items, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return nil, err
}
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
for i := range items {
items[i] = domain.CopyRunUpdateJob(items[i])
}
return items, nil
}
func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) {
if err := validator.ValidateDependencyExecutionInputRequest(request); err != nil {
return domain.DependencyExecutionInput{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if job.Capability != domain.JobCapabilityDependenciesCheck && job.Capability != domain.JobCapabilityDependenciesInstall {
return domain.DependencyExecutionInput{}, validationError("job is not a dependency operation")
}
resolution, err := svc.resolveDependencyContext(job.ServerInstanceID)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if resolution.endpoint.ID != job.RunEndpointID {
return domain.DependencyExecutionInput{}, validationError("dependency endpoint no longer matches")
}
probeKey := strings.TrimPrefix(job.TargetKey, "dependencies/")
if job.Capability == domain.JobCapabilityDependenciesInstall {
probeKey = ""
}
var probe domain.RuntimeDependencyProbe
if probeKey != "" {
probe, err = declaredDependencyProbe(resolution.plugin, probeKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
}
var plan domain.RuntimeInstallPlan
if job.Capability == domain.JobCapabilityDependenciesInstall {
planKey := strings.TrimPrefix(job.TargetKey, "dependencies/install/")
plan, err = declaredInstallPlan(resolution.plugin, planKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
statuses, listErr := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: job.ServerInstanceID})
if listErr != nil {
return domain.DependencyExecutionInput{}, listErr
}
for _, status := range statuses {
if status.JobID == job.ID {
probe, err = declaredDependencyProbe(resolution.plugin, status.ProbeKey, resolution.endpoint.Platform)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
break
}
}
}
digest := dependencyPlanDigest(resolution, probe, plan)
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
if status.PlanDigest != digest {
return domain.DependencyExecutionInput{}, validationError("dependency declaration changed after dispatch")
}
bindings, err := dependencyBindings(resolution.binding, probe, plan)
if err != nil {
return domain.DependencyExecutionInput{}, err
}
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, Probe: probe, Plan: plan, Bindings: bindings}), nil
}
func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) {
if err := validator.ValidateRunUpdateInputRequest(request); err != nil {
return domain.RunUpdateInput{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.RunUpdateInput{}, err
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return domain.RunUpdateInput{}, validationError("job is not a Run self-update")
}
update, distribution, artifact, err := svc.resolveRunUpdate(job)
if err != nil {
return domain.RunUpdateInput{}, err
}
return domain.RunUpdateInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, ArtifactID: artifact.ID, Checksum: artifact.Checksum, SizeBytes: artifact.SizeBytes, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, PackageFormat: distribution.PackageFormat, ExecutableName: executableFilename("run", distribution.TargetOS), TargetRelease: update.TargetRelease, ChunkSizeBytes: runUpdateChunkSize}, nil
}
func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
return domain.RunUpdateChunk{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.RunUpdateChunk{}, err
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return domain.RunUpdateChunk{}, validationError("job is not a Run self-update")
}
_, _, artifact, err := svc.resolveRunUpdate(job)
if err != nil {
return domain.RunUpdateChunk{}, err
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunUpdateChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunUpdateChunk{}, validationError("update artifact content does not match metadata")
}
if request.Offset >= artifact.SizeBytes {
return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact")
}
length := request.Length
remaining := artifact.SizeBytes - request.Offset
if int64(length) > remaining {
length = int(remaining)
}
end := request.Offset + int64(length)
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil
}
func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) {
session, err := svc.validatedRunSession(endpointID, sessionToken)
if err != nil {
return domain.Job{}, err
}
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, err := svc.fencedJob(session, jobID, leaseToken, attempt)
if err != nil {
return domain.Job{}, err
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.Job{}, validationError("job input is not active")
}
if !job.CancelRequestedAt.IsZero() {
return domain.Job{}, validationError("job input is cancelled")
}
return job, nil
}
func (svc *CoreService) resolveDependencyContext(serverInstanceID string) (dependencyResolution, error) {
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return dependencyResolution{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return dependencyResolution{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion {
return dependencyResolution{}, validationError("installed plugin version does not match server")
}
binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil {
return dependencyResolution{}, err
}
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return dependencyResolution{}, err
}
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
return dependencyResolution{}, validationError("runtime binding is incomplete or stale")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return dependencyResolution{}, err
}
if endpoint.Platform == "" || endpoint.Architecture == "" {
return dependencyResolution{}, validationError("Run endpoint target is not registered")
}
return dependencyResolution{instance: instance, plugin: plugin, binding: binding, endpoint: endpoint}, nil
}
func declaredDependencyProbe(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeDependencyProbe, error) {
for _, probe := range plugin.RuntimeProfiles.DependencyProbes {
if probe.Key == key && runtimePlatformsContain(probe.Platforms, targetOS) {
return probe, nil
}
}
return domain.RuntimeDependencyProbe{}, validationError("dependency probe is not declared for endpoint target")
}
func declaredInstallPlan(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeInstallPlan, error) {
for _, plan := range plugin.RuntimeProfiles.InstallPlans {
if plan.Key == key && runtimePlatformsContain(plan.Platforms, targetOS) {
return plan, nil
}
}
return domain.RuntimeInstallPlan{}, validationError("dependency install plan is not declared for endpoint target")
}
func runtimePlatformsContain(platforms []string, target string) bool {
if len(platforms) == 0 {
return true
}
for _, platform := range platforms {
if platform == target {
return true
}
}
return false
}
func planTargetsProbe(plan domain.RuntimeInstallPlan, probe domain.RuntimeDependencyProbe) bool {
for _, step := range plan.Steps {
if step.TargetKey == probe.TargetKey {
return true
}
}
return false
}
func dependencyPlanDigest(resolution dependencyResolution, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) string {
keys := make([]string, 0, len(resolution.binding.Bindings))
for key := range resolution.binding.Bindings {
keys = append(keys, key)
}
sort.Strings(keys)
bindingEvidence := make([]string, 0, len(keys))
for _, key := range keys {
bindingEvidence = append(bindingEvidence, key+"="+validator.BytesChecksum([]byte(resolution.binding.Bindings[key])))
}
payload := struct {
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
ProfileKey string `json:"profileKey"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
Binding []string `json:"binding"`
Probe domain.RuntimeDependencyProbe `json:"probe"`
Plan domain.RuntimeInstallPlan `json:"plan"`
}{resolution.plugin.ID, resolution.plugin.Version, resolution.binding.ProfileKey, resolution.endpoint.Platform, resolution.endpoint.Architecture, bindingEvidence, probe, plan}
body, _ := json.Marshal(payload)
return validator.BytesChecksum(body)
}
func dependencyBindings(binding domain.RuntimeBinding, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) (map[string]string, error) {
keys := map[string]struct{}{}
if probe.TargetKey != "" {
keys[probe.TargetKey] = struct{}{}
}
for _, step := range plan.Steps {
if step.TargetKey != "" {
keys[step.TargetKey] = struct{}{}
}
}
out := make(map[string]string, len(keys))
for key := range keys {
value := strings.TrimSpace(binding.Bindings[key])
if value == "" {
value = key
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "secret://") || strings.Contains(lower, "password=") || strings.Contains(lower, "token=") {
return nil, validationError("dependency target binding cannot be a secret")
}
out[key] = value
}
return out, nil
}
func (svc *CoreService) dependencyStatusForJob(jobID, serverInstanceID string) (domain.DependencyStatus, error) {
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.DependencyStatus{}, err
}
for _, status := range statuses {
if status.JobID == jobID {
return status, nil
}
}
return domain.DependencyStatus{}, repo.ErrNotFound
}
func (svc *CoreService) resolveRunUpdate(job domain.Job) (domain.RunUpdateJob, domain.RunDistribution, domain.Artifact, error) {
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
var update domain.RunUpdateJob
for _, candidate := range updates {
if candidate.JobID == job.ID {
update = candidate
break
}
}
if update.ID == "" || update.RunEndpointID != job.RunEndpointID {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update record does not match active job")
}
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID, Status: domain.DistributionStatusAvailable})
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
var distribution domain.RunDistribution
for _, candidate := range distributions {
if candidate.ArtifactID == update.ArtifactID {
distribution = candidate
break
}
}
if distribution.ID == "" || distribution.RunEndpointID != job.RunEndpointID || distribution.TargetOS != update.TargetOS || distribution.TargetArch != update.TargetArch || distribution.Checksum != update.Checksum {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run distribution no longer matches update")
}
endpoint, err := svc.store.RunEndpoints().Get(job.RunEndpointID)
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
if endpoint.Platform != distribution.TargetOS || endpoint.Architecture != distribution.TargetArch {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update target no longer matches endpoint")
}
artifact, err := svc.store.Artifacts().Get(update.ArtifactID)
if err != nil {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID || artifact.Checksum != update.Checksum {
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update artifact is unavailable or outside distribution scope")
}
return update, distribution, artifact, nil
}
func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stamp time.Time) error {
if job.Capability == domain.JobCapabilityDependenciesCheck || job.Capability == domain.JobCapabilityDependenciesInstall {
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
if err != nil {
return err
}
status.UpdatedAt = stamp
status.CheckedAt = stamp
status.JobID = job.ID
if job.State == domain.JobStateSucceeded {
var evidence domain.DependencyExecutionEvidence
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil {
return validationError("dependency result evidence is invalid")
}
if evidence.ProbeKey != status.ProbeKey || evidence.PlanDigest != status.PlanDigest || job.ExecutionResult.Checksum != status.PlanDigest {
return validationError("dependency result evidence does not match approved plan")
}
status.State = domain.DependencyState(evidence.State)
status.Evidence = evidence.Evidence
status.CompletedSteps = evidence.CompletedSteps
status.Message = "dependency execution completed"
} else if job.State == domain.JobStateCancelled {
status.State = domain.DependencyStateFailed
status.Message = "dependency execution cancelled"
} else {
status.State = domain.DependencyStateFailed
status.Message = "dependency execution failed"
}
if err := validator.ValidateDependencyStatus(status); err != nil {
return err
}
if err := svc.store.DependencyStatuses().Update(status); err != nil {
return err
}
return svc.recordAuditEvent("run", "dependency.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), status.Message)
}
if job.Capability != domain.JobCapabilityRunSelfUpdate {
return nil
}
update, _, _, err := svc.resolveRunUpdate(job)
if err != nil {
return err
}
update.UpdatedAt = stamp
if job.State == domain.JobStateSucceeded {
var evidence domain.RunUpdateExecutionEvidence
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil || evidence.TargetRelease != update.TargetRelease || evidence.Phase != "staged" || job.ExecutionResult.Checksum != update.Checksum {
return validationError("Run update staged evidence is invalid")
}
update.Status = domain.DistributionJobStatusRunning
update.Phase = domain.RunUpdatePhaseRestartRequested
update.Message = "verified update staged; restart requested"
} else if job.State == domain.JobStateCancelled {
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseFailed
update.Message = "Run update cancelled before activation"
} else {
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseFailed
update.Message = "Run update verification or staging failed"
}
if err := validator.ValidateRunUpdateJob(update); err != nil {
return err
}
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
return err
}
return svc.recordAuditEvent("run", "run.update.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), update.Message)
}
func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error {
if job.Capability == domain.JobCapabilityRunSelfUpdate {
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, update := range updates {
if update.JobID != job.ID || update.Status != domain.DistributionJobStatusQueued {
continue
}
update.Status = domain.DistributionJobStatusRunning
update.Phase = domain.RunUpdatePhaseDownloading
update.Message = "Run is downloading and verifying the update"
update.UpdatedAt = stamp
if err := validator.ValidateRunUpdateJob(update); err != nil {
return err
}
return svc.store.RunUpdateJobs().Update(update)
}
}
return nil
}
func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) {
if err := validator.ValidateRunUpdateHealthReport(report); err != nil {
return domain.RunUpdateHealthResult{}, err
}
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
return domain.RunUpdateHealthResult{}, err
}
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, err := svc.store.Jobs().Get(report.JobID)
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
if job.RunEndpointID != report.RunEndpointID || job.Capability != domain.JobCapabilityRunSelfUpdate || job.State != domain.JobStateSucceeded || job.Attempt != report.Attempt || !leaseTokenMatches(job.LeaseTokenHash, report.LeaseToken) {
return domain.RunUpdateHealthResult{}, validationError("Run update health report does not match terminal attempt")
}
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
var update domain.RunUpdateJob
for _, candidate := range updates {
if candidate.JobID == job.ID && candidate.RunEndpointID == report.RunEndpointID {
update = candidate
break
}
}
if update.ID == "" || job.ExecutionResult.Checksum != update.Checksum {
return domain.RunUpdateHealthResult{}, validationError("Run update health report does not match staged update")
}
endpoint, err := svc.store.RunEndpoints().Get(report.RunEndpointID)
if err != nil {
return domain.RunUpdateHealthResult{}, err
}
if endpoint.Version != report.Version || endpoint.Status != domain.RunEndpointStatusOnline {
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match online endpoint")
}
stamp := svc.now()
if report.Outcome == "succeeded" {
if report.Version != update.TargetRelease || update.Phase == domain.RunUpdatePhaseRolledBack || update.Phase == domain.RunUpdatePhaseFailed {
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match target release")
}
if update.Phase == domain.RunUpdatePhaseSucceeded {
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
update.Status = domain.DistributionJobStatusSucceeded
update.Phase = domain.RunUpdatePhaseSucceeded
update.Rollback = false
update.Message = "updated Run registered, reconciled, and reported healthy"
} else {
if update.PreviousVersion != "" && report.Version != update.PreviousVersion {
return domain.RunUpdateHealthResult{}, validationError("rolled-back Run version does not match previous release")
}
if update.Phase == domain.RunUpdatePhaseRolledBack {
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
update.Status = domain.DistributionJobStatusFailed
update.Phase = domain.RunUpdatePhaseRolledBack
update.Rollback = true
update.Message = "Run update activation failed and previous executable was restored"
}
update.UpdatedAt = stamp
if err := validator.ValidateRunUpdateJob(update); err != nil {
return domain.RunUpdateHealthResult{}, err
}
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
return domain.RunUpdateHealthResult{}, err
}
auditResult := domain.AuditResultSuccess
if report.Outcome == "rolled-back" {
auditResult = domain.AuditResultFailed
}
if err := svc.recordAuditEvent("run", "run.update.health", "server-instance", update.ServerInstanceID, auditResult, update.Message); err != nil {
return domain.RunUpdateHealthResult{}, err
}
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
}
func auditResultForJob(job domain.Job) domain.AuditResult {
if job.State == domain.JobStateSucceeded {
return domain.AuditResultSuccess
}
if job.State == domain.JobStateCancelled {
return domain.AuditResultDenied
}
return domain.AuditResultFailed
}
func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey
}
func findRunDistributionForArtifact(distributions []domain.RunDistribution, artifactID string) (domain.RunDistribution, error) {
for _, distribution := range distributions {
if distribution.ArtifactID == artifactID && distribution.Status == domain.DistributionStatusAvailable {
return distribution, nil
}
}
return domain.RunDistribution{}, errors.New("available Run distribution not found")
}
+290
View File
@@ -0,0 +1,290 @@
package service
import (
"encoding/json"
"errors"
"strings"
"testing"
"browser.local/platform/domain"
)
func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
otherSession := createServiceUserAndLogin(t, svc, domain.User{ID: "dependency-other-owner", DisplayName: "Other Owner", Email: "dependency-other@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
if _, err := svc.GetDependencyCatalogForSession(otherSession, instance.ID); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected cross-owner dependency catalog denial, got %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
if catalog.TargetOS != "linux" || catalog.TargetArch != "amd64" || len(catalog.Probes) != 1 || len(catalog.Plans) != 1 || !strings.HasPrefix(catalog.Plans[0].Digest, "sha256:") {
t.Fatalf("unexpected dependency catalog: %+v", catalog)
}
request := domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, Install: true, InstallPlanKey: catalog.Plans[0].Key, PlanDigest: "sha256:" + strings.Repeat("f", 64), IdempotencyKey: "dependency-stale-digest"}
if _, err := svc.QueueDependencyJobForSession(session, request); err == nil || !strings.Contains(err.Error(), "planDigest") {
t.Fatalf("expected stale digest rejection, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list jobs: %v", err)
}
for _, job := range jobs {
if job.IdempotencyKey == request.IdempotencyKey {
t.Fatalf("stale digest created a job: %+v", job)
}
}
audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID})
if err != nil {
t.Fatalf("list audits: %v", err)
}
foundDenied := false
for _, audit := range audits {
foundDenied = foundDenied || audit.Action == "dependency.install.denied"
}
if !foundDenied {
t.Fatalf("expected stale digest audit, got %+v", audits)
}
request.PlanDigest = catalog.Plans[0].Digest
request.IdempotencyKey = "dependency-current-digest"
job, err := svc.QueueDependencyJobForSession(session, request)
if err != nil {
t.Fatalf("queue reviewed dependency plan: %v", err)
}
if job.Capability != domain.JobCapabilityDependenciesInstall || job.TargetKey != "dependencies/install/"+catalog.Plans[0].Key {
t.Fatalf("unexpected dependency install job: %+v", job)
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.RuntimeProfiles.InstallPlans[0].Steps[0].PackageName = "openjdk-22-jre"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("mutate plugin declaration: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesInstall}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob {
t.Fatalf("claim dependency install: claim=%+v err=%v", claim, err)
}
_, err = svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err == nil || !strings.Contains(err.Error(), "changed after dispatch") {
t.Fatalf("expected changed declaration rejection, got %v", err)
}
}
func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("catalog: %v", err)
}
job, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-fencing"})
if err != nil {
t.Fatalf("queue dependency check: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID {
t.Fatalf("claim dependency check: claim=%+v err=%v", claim, err)
}
base := domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetDependencyExecutionInput(base)
if err != nil || input.PlanDigest == "" || input.Bindings["java"] == "" {
t.Fatalf("get fenced dependency input: input=%+v err=%v", input, err)
}
wrongSession := base
wrongSession.SessionToken = "stale-session"
if _, err := svc.GetDependencyExecutionInput(wrongSession); err == nil {
t.Fatal("expected wrong session rejection")
}
wrongAttempt := base
wrongAttempt.Attempt++
if _, err := svc.GetDependencyExecutionInput(wrongAttempt); err == nil {
t.Fatal("expected wrong attempt rejection")
}
wrongLease := base
wrongLease.LeaseToken = "stale-lease"
if _, err := svc.GetDependencyExecutionInput(wrongLease); err == nil {
t.Fatal("expected wrong lease rejection")
}
evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, AuditSummary: "dependency probe completed", Content: string(evidence)}}); err != nil {
t.Fatalf("complete dependency result: %v", err)
}
projected, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil || projected.Probes[0].State != domain.DependencyStatePresent || projected.Probes[0].Evidence != "OpenJDK 21" {
t.Fatalf("unexpected dependency projection: catalog=%+v err=%v", projected, err)
}
cancelJob, err := svc.QueueDependencyJobForSession(session, domain.DependencyJobRequest{ServerInstanceID: instance.ID, ProbeKey: catalog.Probes[0].Key, IdempotencyKey: "dependency-check-cancel"})
if err != nil {
t.Fatalf("queue cancellable dependency check: %v", err)
}
claim, err = svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityDependenciesCheck}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || claim.Job.JobID != cancelJob.ID {
t.Fatalf("claim cancellable dependency check: claim=%+v err=%v", claim, err)
}
if _, err := svc.RequestRunJobCancelForSession(session, domain.RunJobCancelRequest{JobID: cancelJob.ID, Reason: "operator cancelled"}); err != nil {
t.Fatalf("request cancel: %v", err)
}
if _, err := svc.GetDependencyExecutionInput(domain.DependencyExecutionInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("expected cancelled input rejection, got %v", err)
}
}
func TestPluginBridgeDependencyInstallUsesReviewedPlanDigest(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatalf("get plugin: %v", err)
}
plugin.Pages = append(plugin.Pages, domain.GamePluginPage{Key: "runtime", Title: "Runtime", Path: "/runtime", Permissions: []string{"server.dependencies.manage"}, BridgeActions: []string{string(domain.PluginBridgeActionDependenciesRequest)}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("add dependency bridge page: %v", err)
}
catalog, err := svc.GetDependencyCatalogForSession(session, instance.ID)
if err != nil {
t.Fatalf("get dependency catalog: %v", err)
}
request := domain.PluginBridgeExecuteRequest{RequestID: "bridge-dependency-install", PluginID: plugin.ID, RouteKey: "runtime", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionDependenciesRequest, Payload: map[string]string{"operation": "install", "probeKey": catalog.Probes[0].Key, "planKey": catalog.Plans[0].Key, "idempotencyKey": "bridge-dependency-install"}}
denied, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute bridge without digest: %v", err)
}
if denied.Status == "queued" || denied.Error == nil {
t.Fatalf("bridge install without reviewed digest must be denied: %+v", denied)
}
request.Payload["planDigest"] = catalog.Plans[0].Digest
request.Payload["idempotencyKey"] = "bridge-dependency-install-approved"
approved, err := svc.ExecutePluginBridgeAction(session, request)
if err != nil {
t.Fatalf("execute reviewed bridge install: %v", err)
}
if approved.Status != "queued" || approved.Result["capability"] != domain.JobCapabilityDependenciesInstall {
t.Fatalf("expected reviewed bridge dependency job, got %+v", approved)
}
}
func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ServerInstanceID: instance.ID, TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "run-update-build"})
if err != nil {
t.Fatalf("generate update distribution: %v", err)
}
payload := []byte("compiled target-matched run archive")
distribution = completeDistributionBuild(t, svc, distribution, payload)
otherInstance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-update-other", PluginID: instance.PluginID, RunEndpointID: instance.RunEndpointID, Name: "Other Update Server", State: domain.ServerInstanceStateReady})
if err != nil {
t.Fatalf("create other update server: %v", err)
}
createCompleteRuntimeBinding(t, svc, otherInstance, "local")
if _, err := svc.PushRunUpdateForSession(session, domain.RunUpdateRequest{ServerInstanceID: otherInstance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-cross-server"}); err == nil {
t.Fatal("expected cross-server update artifact rejection")
}
endpoint, _ := svc.store.RunEndpoints().Get(instance.RunEndpointID)
endpoint.Architecture = "arm64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("change endpoint target: %v", err)
}
request := domain.RunUpdateRequest{ServerInstanceID: instance.ID, ArtifactID: distribution.ArtifactID, Checksum: distribution.Checksum, IdempotencyKey: "run-update-target-check"}
if _, err := svc.PushRunUpdateForSession(session, request); err == nil || !strings.Contains(err.Error(), "target-matched") {
t.Fatalf("expected cross-target update rejection, got %v", err)
}
endpoint.Architecture = "amd64"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("restore endpoint target: %v", err)
}
request.IdempotencyKey = "run-update-fenced"
update, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push target-matched update: %v", err)
}
runSession := registerDependencyUpdateRun(t, svc, instance)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRunSelfUpdate}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job.JobID != update.JobID {
t.Fatalf("claim Run update: claim=%+v err=%v", claim, err)
}
inputRequest := domain.RunUpdateInputRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}
input, err := svc.GetRunUpdateInput(inputRequest)
if err != nil || input.TargetRelease != update.TargetRelease || input.Checksum != distribution.Checksum {
t.Fatalf("get Run update input: input=%+v err=%v", input, err)
}
chunk, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Offset: 0, Length: 8})
if err != nil || string(chunk.Payload) != string(payload[:8]) || chunk.Offset != 0 || chunk.TotalBytes != int64(len(payload)) {
t.Fatalf("read bounded update chunk: chunk=%+v err=%v", chunk, err)
}
if _, err := svc.ReadRunUpdateChunk(domain.RunUpdateChunkRequest{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt + 1, Offset: 0, Length: 8}); err == nil {
t.Fatal("expected stale update chunk attempt rejection")
}
evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), AuditSummary: "verified update staged", Content: string(evidence)}}); err != nil {
t.Fatalf("complete staged Run update: %v", err)
}
updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID)
if err != nil || len(updates) != 1 || updates[0].Phase != domain.RunUpdatePhaseRestartRequested {
t.Fatalf("expected restart-requested projection, updates=%+v err=%v", updates, err)
}
newHello := dependencyUpdateHello(instance)
newHello.Version = update.TargetRelease
newRegistration, err := svc.RegisterRunHello(newHello)
if err != nil {
t.Fatalf("register updated Run: %v", err)
}
health := domain.RunUpdateHealthReport{RunEndpointID: instance.RunEndpointID, SessionToken: newRegistration.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Outcome: "succeeded", Version: update.TargetRelease}
if _, err := svc.ReportRunUpdateHealth(domain.RunUpdateHealthReport{RunEndpointID: health.RunEndpointID, SessionToken: health.SessionToken, JobID: health.JobID, LeaseToken: "stale-lease", Attempt: health.Attempt, Outcome: health.Outcome, Version: health.Version}); err == nil {
t.Fatal("expected stale health lease rejection")
}
result, err := svc.ReportRunUpdateHealth(health)
if err != nil || !result.Accepted || result.Phase != domain.RunUpdatePhaseSucceeded {
t.Fatalf("report updated Run health: result=%+v err=%v", result, err)
}
rollbackHello := dependencyUpdateHello(instance)
rollbackHello.Version = update.PreviousVersion
rollbackRegistration, err := svc.RegisterRunHello(rollbackHello)
if err != nil {
t.Fatalf("register rolled-back Run: %v", err)
}
health.SessionToken = rollbackRegistration.SessionToken
health.Outcome = "rolled-back"
health.Version = update.PreviousVersion
result, err = svc.ReportRunUpdateHealth(health)
if err != nil || result.Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("report rollback: result=%+v err=%v", result, err)
}
updates, _ = svc.ListRunUpdateJobsForSession(session, instance.ID)
if !updates[0].Rollback || updates[0].Status != domain.DistributionJobStatusFailed || updates[0].Phase != domain.RunUpdatePhaseRolledBack {
t.Fatalf("unexpected rollback projection: %+v", updates[0])
}
}
func registerDependencyUpdateRun(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
result, err := svc.RegisterRunHello(dependencyUpdateHello(instance))
if err != nil || !result.Accepted {
t.Fatalf("register dependency/update Run: result=%+v err=%v", result, err)
}
return result.SessionToken
}
func dependencyUpdateHello(instance domain.ServerInstance) domain.RunControlHello {
return domain.RunControlHello{
RegistrationToken: "registration-token",
RunEndpointID: instance.RunEndpointID,
DisplayName: "Dependency Update Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "linux",
Architecture: "amd64",
CapabilityReport: domain.RunCapabilityReport{Capabilities: []string{domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate}, Fingerprint: "dependency-update-v1"},
Capacity: domain.RunCapacity{MaxJobs: 2},
}
}
+61 -4
View File
@@ -14,12 +14,13 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui
if err := validator.ValidateDistributionBuildInputRequest(request); err != nil {
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"
+150 -194
View File
@@ -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"))
}
+88 -1
View File
@@ -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
}
@@ -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)
}
}
+435 -167
View File
@@ -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 {
+8 -5
View File
@@ -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)
}
}
+241
View File
@@ -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)
}
}
+22 -14
View File
@@ -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 {
+264
View File
@@ -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
}
+174
View File
@@ -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 ""
}
}
+161
View File
@@ -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
}
+306 -100
View File
@@ -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 {
+67 -2
View File
@@ -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"}}}},
},
}
}
+224
View File
@@ -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
}
+109
View File
@@ -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"}}},
}
}
+98
View File
@@ -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)
}
+42
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
+73
View File
@@ -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 ""
}
@@ -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) {
+7 -1
View File
@@ -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)
+7 -1
View File
@@ -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")
}
+52
View File
@@ -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)
}
@@ -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:<hex>")
}
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)
}
@@ -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")
}
}
+14
View File
@@ -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)
+39 -3
View File
@@ -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:<hex>")
}
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:<hex>")
@@ -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) {
+45 -13
View File
@@ -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:<hex>")
}
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)
}
+100
View File
@@ -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)
}
+23
View File
@@ -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 }
+68 -1
View File
@@ -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:<hex>")
}
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:<hex>")
}
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
}
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
+20
View File
@@ -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")
+440
View File
@@ -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
}
+2 -1
View File
@@ -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)}}