feat: 完整游戏运维功能
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// MetricSample is a bounded, platform-owned observation for one server instance.
|
||||
type MetricSample struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
RunEndpointID string
|
||||
Online bool
|
||||
PlayerCount *int
|
||||
MaxPlayers *int
|
||||
TPS *float64
|
||||
LatencyMS *float64
|
||||
CPUPercent *float64
|
||||
MemoryPercent *float64
|
||||
DiskPercent *float64
|
||||
Source string
|
||||
CollectedAt time.Time
|
||||
}
|
||||
|
||||
type MetricSampleFilter struct {
|
||||
ServerInstanceID string
|
||||
After time.Time
|
||||
Before time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type MetricBatchIngest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
Samples []MetricSample
|
||||
}
|
||||
|
||||
type MetricBatchIngestResult struct {
|
||||
Accepted bool
|
||||
AcceptedCount int
|
||||
LatestAt time.Time
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type BackupState string
|
||||
|
||||
const (
|
||||
BackupStatePending BackupState = "pending"
|
||||
BackupStateAvailable BackupState = "available"
|
||||
BackupStateFailed BackupState = "failed"
|
||||
BackupStateExpired BackupState = "expired"
|
||||
)
|
||||
|
||||
type BackupRecord struct {
|
||||
ID string
|
||||
ServerInstanceID string
|
||||
ArtifactID string
|
||||
Checksum string
|
||||
SizeBytes int64
|
||||
State BackupState
|
||||
RecoveryStatus string
|
||||
RetentionUntil time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type BackupFilter struct {
|
||||
ServerInstanceID string
|
||||
State BackupState
|
||||
}
|
||||
|
||||
type RemoteAdapterKind string
|
||||
|
||||
const (
|
||||
RemoteAdapterFTP RemoteAdapterKind = "ftp"
|
||||
RemoteAdapterRsync RemoteAdapterKind = "rsync"
|
||||
RemoteAdapterRunFile RemoteAdapterKind = "run-file"
|
||||
RemoteAdapterRunProcess RemoteAdapterKind = "run-process"
|
||||
RemoteAdapterDatabase RemoteAdapterKind = "database"
|
||||
RemoteAdapterRCON RemoteAdapterKind = "rcon"
|
||||
)
|
||||
|
||||
type RemoteAdapterDeclaration struct {
|
||||
Key string
|
||||
Kind RemoteAdapterKind
|
||||
TargetKeys []string
|
||||
Capabilities []string
|
||||
TimeoutSeconds int
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
type RemoteAdapterRequest struct {
|
||||
ServerInstanceID string
|
||||
DeclarationKey string
|
||||
TargetKey string
|
||||
Capability string
|
||||
TimeoutSeconds int
|
||||
MaxAttempts int
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type RemoteAdapterResult struct {
|
||||
RequestID string
|
||||
ServerInstanceID string
|
||||
DeclarationKey string
|
||||
TargetKey string
|
||||
Kind RemoteAdapterKind
|
||||
Status string
|
||||
Retryable bool
|
||||
Message string
|
||||
ResultRef string
|
||||
AuditEventID string
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
func CopyMetricSample(sample MetricSample) MetricSample {
|
||||
sample.PlayerCount = copyIntPtr(sample.PlayerCount)
|
||||
sample.MaxPlayers = copyIntPtr(sample.MaxPlayers)
|
||||
sample.TPS = copyFloatPtr(sample.TPS)
|
||||
sample.LatencyMS = copyFloatPtr(sample.LatencyMS)
|
||||
sample.CPUPercent = copyFloatPtr(sample.CPUPercent)
|
||||
sample.MemoryPercent = copyFloatPtr(sample.MemoryPercent)
|
||||
sample.DiskPercent = copyFloatPtr(sample.DiskPercent)
|
||||
return sample
|
||||
}
|
||||
|
||||
func copyIntPtr(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func copyFloatPtr(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func CopyMetricSamples(samples []MetricSample) []MetricSample {
|
||||
if samples == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]MetricSample, len(samples))
|
||||
for i, sample := range samples {
|
||||
out[i] = CopyMetricSample(sample)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyMetricBatchIngest(batch MetricBatchIngest) MetricBatchIngest {
|
||||
batch.Samples = CopyMetricSamples(batch.Samples)
|
||||
return batch
|
||||
}
|
||||
|
||||
func CopyBackupRecord(record BackupRecord) BackupRecord { return record }
|
||||
|
||||
func CopyBackupRecords(records []BackupRecord) []BackupRecord {
|
||||
if records == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]BackupRecord, len(records))
|
||||
copy(out, records)
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyRemoteAdapterDeclaration(declaration RemoteAdapterDeclaration) RemoteAdapterDeclaration {
|
||||
declaration.TargetKeys = CopyStringSlice(declaration.TargetKeys)
|
||||
declaration.Capabilities = CopyStringSlice(declaration.Capabilities)
|
||||
return declaration
|
||||
}
|
||||
|
||||
func CopyRemoteAdapterDeclarations(declarations []RemoteAdapterDeclaration) []RemoteAdapterDeclaration {
|
||||
if declarations == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]RemoteAdapterDeclaration, len(declarations))
|
||||
for i, declaration := range declarations {
|
||||
out[i] = CopyRemoteAdapterDeclaration(declaration)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest { return request }
|
||||
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
||||
+390
-36
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -93,6 +93,7 @@ type AuthSessionResponse struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type AIProviderCreateRequest struct {
|
||||
@@ -125,17 +126,17 @@ type AIProviderStatusRequest struct {
|
||||
}
|
||||
|
||||
type AIProviderResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind domain.AIProviderKind `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKeyRef string `json:"apiKeyRef"`
|
||||
Models []string `json:"models"`
|
||||
DefaultModel string `json:"defaultModel,omitempty"`
|
||||
RelayMode domain.AIRelayMode `json:"relayMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
Status domain.AIProviderStatus `json:"status"`
|
||||
RedactionPolicy string `json:"redactionPolicy"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind domain.AIProviderKind `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKeyConfigured bool `json:"apiKeyConfigured"`
|
||||
Models []string `json:"models"`
|
||||
DefaultModel string `json:"defaultModel,omitempty"`
|
||||
RelayMode domain.AIRelayMode `json:"relayMode"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
Status domain.AIProviderStatus `json:"status"`
|
||||
RedactionPolicy string `json:"redactionPolicy"`
|
||||
}
|
||||
|
||||
type AIProviderListResponse struct {
|
||||
@@ -206,20 +207,21 @@ type GamePluginRemoteAccessBody struct {
|
||||
}
|
||||
|
||||
type GamePluginManifestBody struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Server GamePluginManifestServerBody `json:"server"`
|
||||
Bridge GamePluginBridgeBody `json:"bridge,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Server GamePluginManifestServerBody `json:"server"`
|
||||
Bridge GamePluginBridgeBody `json:"bridge,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Actions PluginLifecycleActionsBody `json:"actions"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
AI GamePluginManifestAIBody `json:"ai,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginManifestRegistrationRequest struct {
|
||||
@@ -228,48 +230,50 @@ type GamePluginManifestRegistrationRequest struct {
|
||||
}
|
||||
|
||||
type GamePluginCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions,omitempty"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions,omitempty"`
|
||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||
Pages []GamePluginPageBody `json:"pages,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AIPurposes []string `json:"aiPurposes,omitempty"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
}
|
||||
|
||||
type GamePluginResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
}
|
||||
|
||||
type GamePluginListResponse struct {
|
||||
@@ -278,27 +282,28 @@ type GamePluginListResponse struct {
|
||||
}
|
||||
|
||||
type MarketplacePluginResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
ServerType string `json:"serverType"`
|
||||
ServerDisplayName string `json:"serverDisplayName,omitempty"`
|
||||
SupportedOS []string `json:"supportedOs,omitempty"`
|
||||
ManifestRef string `json:"manifestRef"`
|
||||
CreateFormSchemaRef string `json:"createFormSchemaRef"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
DeclaredPermissions []string `json:"declaredPermissions"`
|
||||
Permissions PluginPermissionsResponse `json:"permissions"`
|
||||
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
|
||||
BridgeActions []string `json:"bridgeActions"`
|
||||
Pages []GamePluginPageBody `json:"pages"`
|
||||
Tags []string `json:"tags"`
|
||||
AIPurposes []string `json:"aiPurposes"`
|
||||
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
|
||||
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
|
||||
ValidationViolations []string `json:"validationViolations,omitempty"`
|
||||
Status domain.GamePluginStatus `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MarketplacePluginListResponse struct {
|
||||
@@ -389,17 +394,20 @@ type ServerMemberListResponse struct {
|
||||
}
|
||||
|
||||
type ServerInstanceResponse struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Name string `json:"name"`
|
||||
OwnerUserID string `json:"ownerUserId,omitempty"`
|
||||
AdminUserIDs []string `json:"adminUserIds"`
|
||||
State domain.ServerInstanceState `json:"state"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Name string `json:"name"`
|
||||
OwnerUserID string `json:"ownerUserId,omitempty"`
|
||||
AdminUserIDs []string `json:"adminUserIds"`
|
||||
State domain.ServerInstanceState `json:"state"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
ConfigKey string `json:"configKey,omitempty"`
|
||||
ConfigChecksum string `json:"configChecksum,omitempty"`
|
||||
ConfigUpdatedAt *time.Time `json:"configUpdatedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ServerInstanceListResponse struct {
|
||||
@@ -440,6 +448,7 @@ type ServerConfigResponse struct {
|
||||
Format string `json:"format"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Checksum string `json:"checksum"`
|
||||
Source string `json:"source,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -453,6 +462,7 @@ type ConfigDiffLineResponse struct {
|
||||
|
||||
type ServerConfigDiffPreviewRequest struct {
|
||||
ExpectedConfigVersion int `json:"expectedConfigVersion"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
Key string `json:"key"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"`
|
||||
@@ -461,6 +471,7 @@ type ServerConfigDiffPreviewRequest struct {
|
||||
type ServerConfigDiffPreviewResponse struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
Checksum string `json:"checksum"`
|
||||
Key string `json:"key"`
|
||||
CurrentContent string `json:"currentContent"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
@@ -473,6 +484,7 @@ type ServerConfigDiffPreviewResponse struct {
|
||||
|
||||
type ServerConfigWriteApprovalRequest struct {
|
||||
ExpectedConfigVersion int `json:"expectedConfigVersion"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
Key string `json:"key"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
ProposedContentInputRef string `json:"proposedContentInputRef,omitempty"`
|
||||
@@ -491,7 +503,9 @@ type FileOperationDispatchRequest struct {
|
||||
Operation domain.FileOperationKind `json:"operation"`
|
||||
Key string `json:"key"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedConfigVersion int `json:"expectedConfigVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
@@ -516,6 +530,8 @@ type RunEndpointCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Status domain.RunEndpointStatus `json:"status"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Capacity RunCapacityResponse `json:"capacity"`
|
||||
@@ -526,6 +542,8 @@ type RunEndpointResponse struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Status domain.RunEndpointStatus `json:"status"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Capacity RunCapacityResponse `json:"capacity"`
|
||||
@@ -553,19 +571,49 @@ type JobProgressBody struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type JobRetryPolicyResponse struct {
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
InitialBackoffSeconds int `json:"initialBackoffSeconds"`
|
||||
MaxBackoffSeconds int `json:"maxBackoffSeconds"`
|
||||
}
|
||||
|
||||
type JobResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State domain.JobState `json:"state"`
|
||||
Progress JobProgressBody `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State domain.JobState `json:"state"`
|
||||
Progress JobProgressBody `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
ExecutionResult JobExecutionResultResponse `json:"executionResult,omitempty"`
|
||||
RetryPolicy JobRetryPolicyResponse `json:"retryPolicy"`
|
||||
Attempt int `json:"attempt"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
AckDeadlineAt *time.Time `json:"ackDeadlineAt,omitempty"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
CancelRequestedAt *time.Time `json:"cancelRequestedAt,omitempty"`
|
||||
CancelCompletedAt *time.Time `json:"cancelCompletedAt,omitempty"`
|
||||
TerminalAt *time.Time `json:"terminalAt,omitempty"`
|
||||
LastReconciledAt *time.Time `json:"lastReconciledAt,omitempty"`
|
||||
ReconcileCount int `json:"reconcileCount"`
|
||||
ReconcileOutcome string `json:"reconcileOutcome,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type JobExecutionResultResponse struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ProcessState string `json:"processState,omitempty"`
|
||||
ExitClassification string `json:"exitClassification,omitempty"`
|
||||
ExitCode int `json:"exitCode,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
AuditSummary string `json:"auditSummary,omitempty"`
|
||||
}
|
||||
|
||||
type JobListResponse struct {
|
||||
@@ -768,20 +816,21 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
|
||||
return domain.GamePluginManifestRegistration{
|
||||
ManifestRef: request.ManifestRef,
|
||||
Manifest: domain.GamePluginManifest{
|
||||
ID: request.Manifest.ID,
|
||||
Name: request.Manifest.Name,
|
||||
Description: request.Manifest.Description,
|
||||
Version: request.Manifest.Version,
|
||||
Kind: request.Manifest.Kind,
|
||||
Tags: domain.CopyStringSlice(request.Manifest.Tags),
|
||||
Server: request.Manifest.Server.ToDomain(),
|
||||
Bridge: request.Manifest.Bridge.ToDomain(),
|
||||
Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities),
|
||||
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
|
||||
Actions: request.Manifest.Actions.ToDomain(),
|
||||
Pages: pagesToDomain(request.Manifest.Pages),
|
||||
AI: request.Manifest.AI.ToDomain(),
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
ID: request.Manifest.ID,
|
||||
Name: request.Manifest.Name,
|
||||
Description: request.Manifest.Description,
|
||||
Version: request.Manifest.Version,
|
||||
Kind: request.Manifest.Kind,
|
||||
Tags: domain.CopyStringSlice(request.Manifest.Tags),
|
||||
Server: request.Manifest.Server.ToDomain(),
|
||||
Bridge: request.Manifest.Bridge.ToDomain(),
|
||||
Capabilities: domain.CopyStringSlice(request.Manifest.Capabilities),
|
||||
Permissions: domain.CopyStringSlice(request.Manifest.Permissions),
|
||||
Actions: request.Manifest.Actions.ToDomain(),
|
||||
Pages: pagesToDomain(request.Manifest.Pages),
|
||||
AI: request.Manifest.AI.ToDomain(),
|
||||
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -843,6 +892,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
|
||||
Tags: domain.CopyStringSlice(request.Tags),
|
||||
AIPurposes: domain.CopyStringSlice(request.AIPurposes),
|
||||
RemoteAccess: request.RemoteAccess.ToDomain(),
|
||||
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
|
||||
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
|
||||
}
|
||||
}
|
||||
@@ -867,6 +917,7 @@ func (request ServerConfigDiffPreviewRequest) ToDomain(serverInstanceID string)
|
||||
return domain.ServerConfigDiffRequest{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
ExpectedConfigVersion: request.ExpectedConfigVersion,
|
||||
ExpectedChecksum: request.ExpectedChecksum,
|
||||
Key: request.Key,
|
||||
ProposedContent: request.ProposedContent,
|
||||
ProposedContentInputRef: request.ProposedContentInputRef,
|
||||
@@ -877,6 +928,7 @@ func (request ServerConfigWriteApprovalRequest) ToDomain(serverInstanceID string
|
||||
return domain.ServerConfigWriteApproval{
|
||||
ServerInstanceID: serverInstanceID,
|
||||
ExpectedConfigVersion: request.ExpectedConfigVersion,
|
||||
ExpectedChecksum: request.ExpectedChecksum,
|
||||
Key: request.Key,
|
||||
ProposedContent: request.ProposedContent,
|
||||
ProposedContentInputRef: request.ProposedContentInputRef,
|
||||
@@ -891,7 +943,9 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa
|
||||
Operation: request.Operation,
|
||||
Key: request.Key,
|
||||
InputRef: request.InputRef,
|
||||
Content: request.Content,
|
||||
ExpectedConfigVersion: request.ExpectedConfigVersion,
|
||||
ExpectedChecksum: request.ExpectedChecksum,
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
}
|
||||
}
|
||||
@@ -901,6 +955,8 @@ func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint {
|
||||
ID: request.ID,
|
||||
DisplayName: request.DisplayName,
|
||||
Version: request.Version,
|
||||
Platform: request.Platform,
|
||||
Architecture: request.Architecture,
|
||||
Status: request.Status,
|
||||
Capabilities: domain.CopyStringSlice(request.Capabilities),
|
||||
Capacity: capacityToDomain(request.Capacity),
|
||||
@@ -988,6 +1044,7 @@ func AuthSessionFromDomain(session domain.AuthSession) AuthSessionResponse {
|
||||
SessionID: session.SessionID,
|
||||
Status: session.Status,
|
||||
Message: session.Message,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,17 +1073,17 @@ func UserListFromDomain(users []domain.User) UserListResponse {
|
||||
func AIProviderFromDomain(provider domain.AIProvider) AIProviderResponse {
|
||||
provider = domain.CopyAIProvider(provider)
|
||||
return AIProviderResponse{
|
||||
ID: provider.ID,
|
||||
Name: provider.Name,
|
||||
Kind: provider.Kind,
|
||||
BaseURL: provider.BaseURL,
|
||||
APIKeyRef: provider.APIKeyRef,
|
||||
Models: provider.Models,
|
||||
DefaultModel: provider.DefaultModel,
|
||||
RelayMode: provider.RelayMode,
|
||||
TimeoutMS: provider.TimeoutMS,
|
||||
Status: provider.Status,
|
||||
RedactionPolicy: provider.RedactionPolicy,
|
||||
ID: provider.ID,
|
||||
Name: provider.Name,
|
||||
Kind: provider.Kind,
|
||||
BaseURL: provider.BaseURL,
|
||||
APIKeyConfigured: provider.APIKeyRef != "",
|
||||
Models: provider.Models,
|
||||
DefaultModel: provider.DefaultModel,
|
||||
RelayMode: provider.RelayMode,
|
||||
TimeoutMS: provider.TimeoutMS,
|
||||
Status: provider.Status,
|
||||
RedactionPolicy: provider.RedactionPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1079,6 +1136,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
}
|
||||
@@ -1167,6 +1225,7 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl
|
||||
Tags: plugin.Tags,
|
||||
AIPurposes: plugin.AIPurposes,
|
||||
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
|
||||
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
|
||||
ValidationViolations: plugin.ValidationViolations,
|
||||
Status: plugin.Status,
|
||||
Source: plugin.Source,
|
||||
@@ -1188,17 +1247,20 @@ func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstanceResp
|
||||
adminUserIDs = []string{}
|
||||
}
|
||||
return ServerInstanceResponse{
|
||||
ID: instance.ID,
|
||||
PluginID: instance.PluginID,
|
||||
PluginVersion: instance.PluginVersion,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Name: instance.Name,
|
||||
OwnerUserID: instance.OwnerUserID,
|
||||
AdminUserIDs: adminUserIDs,
|
||||
State: instance.State,
|
||||
ConfigVersion: instance.ConfigVersion,
|
||||
CreatedAt: instance.CreatedAt,
|
||||
UpdatedAt: instance.UpdatedAt,
|
||||
ID: instance.ID,
|
||||
PluginID: instance.PluginID,
|
||||
PluginVersion: instance.PluginVersion,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Name: instance.Name,
|
||||
OwnerUserID: instance.OwnerUserID,
|
||||
AdminUserIDs: adminUserIDs,
|
||||
State: instance.State,
|
||||
ConfigVersion: instance.ConfigVersion,
|
||||
ConfigKey: instance.ConfigKey,
|
||||
ConfigChecksum: instance.ConfigChecksum,
|
||||
ConfigUpdatedAt: optionalTime(instance.ConfigUpdatedAt),
|
||||
CreatedAt: instance.CreatedAt,
|
||||
UpdatedAt: instance.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,6 +1336,7 @@ func ServerConfigFromDomain(config domain.ServerConfig) ServerConfigResponse {
|
||||
Format: config.Format,
|
||||
Key: config.Key,
|
||||
Content: config.Content,
|
||||
Checksum: config.Checksum,
|
||||
Source: config.Source,
|
||||
UpdatedAt: config.UpdatedAt,
|
||||
}
|
||||
@@ -1293,6 +1356,7 @@ func ServerConfigDiffPreviewFromDomain(preview domain.ServerConfigDiffPreview) S
|
||||
return ServerConfigDiffPreviewResponse{
|
||||
ServerInstanceID: preview.ServerInstanceID,
|
||||
ConfigVersion: preview.ConfigVersion,
|
||||
Checksum: preview.Checksum,
|
||||
Key: preview.Key,
|
||||
CurrentContent: preview.CurrentContent,
|
||||
ProposedContent: preview.ProposedContent,
|
||||
@@ -1332,6 +1396,8 @@ func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
|
||||
ID: endpoint.ID,
|
||||
DisplayName: endpoint.DisplayName,
|
||||
Version: endpoint.Version,
|
||||
Platform: endpoint.Platform,
|
||||
Architecture: endpoint.Architecture,
|
||||
Status: endpoint.Status,
|
||||
Capabilities: endpoint.Capabilities,
|
||||
Capacity: capacityFromDomain(endpoint.Capacity),
|
||||
@@ -1359,11 +1425,36 @@ func JobFromDomain(job domain.Job) JobResponse {
|
||||
State: job.State,
|
||||
Progress: progressFromDomain(job.Progress),
|
||||
ResultRef: job.ResultRef,
|
||||
CreatedAt: job.CreatedAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary},
|
||||
RetryPolicy: JobRetryPolicyResponse{
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds,
|
||||
MaxBackoffSeconds: job.RetryPolicy.MaxBackoffSeconds,
|
||||
},
|
||||
Attempt: job.Attempt,
|
||||
NextAttemptAt: optionalTime(job.NextAttemptAt),
|
||||
AckDeadlineAt: optionalTime(job.AckDeadlineAt),
|
||||
LeaseExpiresAt: optionalTime(job.LeaseExpiresAt),
|
||||
CancelReason: job.CancelReason,
|
||||
CancelRequestedAt: optionalTime(job.CancelRequestedAt),
|
||||
CancelCompletedAt: optionalTime(job.CancelCompletedAt),
|
||||
TerminalAt: optionalTime(job.TerminalAt),
|
||||
LastReconciledAt: optionalTime(job.LastReconciledAt),
|
||||
ReconcileCount: job.ReconcileCount,
|
||||
ReconcileOutcome: job.ReconcileOutcome,
|
||||
CreatedAt: job.CreatedAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func optionalTime(value time.Time) *time.Time {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
copy := value
|
||||
return ©
|
||||
}
|
||||
|
||||
func JobListFromDomain(jobs []domain.Job) JobListResponse {
|
||||
items := make([]JobResponse, len(jobs))
|
||||
for i, job := range jobs {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}}
|
||||
|
||||
Reference in New Issue
Block a user