diff --git a/openspec/changes/make-run-autonomous-lifecycle-owner/design.md b/openspec/changes/make-run-autonomous-lifecycle-owner/design.md index 62239c1..5a54ad7 100644 --- a/openspec/changes/make-run-autonomous-lifecycle-owner/design.md +++ b/openspec/changes/make-run-autonomous-lifecycle-owner/design.md @@ -9,11 +9,13 @@ The corrected ownership model is: Platform builds and authenticates a server-sco **Goals:** - Stop generated Run registration from enqueueing `process.start` or `process.status` jobs as a bootstrap side effect. - Add a generated-package autonomous lifecycle plan containing plugin lifecycle action refs, dependency declarations, safe deployment inputs, log sources, and selected runtime profile metadata. +- Implement the independent Run repository's generic plan loader/executor so generated packages consume `.platform/autonomous-lifecycle-plan.json` locally and report terminal lifecycle facts back to Platform. +- Add a Platform lifecycle report endpoint that authenticates the active Run session, validates the server binding, records audit evidence, and projects visible server state from Run-reported facts without creating jobs. - Keep Platform responsible for server records, registration binding, auth, distribution builds, audit, and visible projections from Run reports. - Update governance and protocol docs so future work treats Run as the lifecycle authority. **Non-Goals:** -- Implement the independent `run` repository's plan executor in this repo. +- Re-add the independent `run` repository as a source tree owned by this repo. - Add game-specific SCUM install/start behavior to Platform. - Move distribution builds to machine-side Run endpoints or expose distribution-build authority to generated Runs. - Remove explicit operator lifecycle command APIs in this change. @@ -24,11 +26,12 @@ The corrected ownership model is: Platform builds and authenticates a server-sco - **Use plugin declarations, not Platform logic, for game behavior.** The plan references action files, dependency probes/install plans, process log sources, DLL extension declarations, and sanitized deployment inputs already declared by the plugin. It does not include SCUM executable names, Steam app IDs, ports, or platform-side command synthesis. - **Make registration binding-only for generated Run bootstrap.** `RegisterRunHello` continues to authenticate the component, upsert endpoint metadata, and issue a session token. It does not dispatch lifecycle or reconciliation work merely because a generated Run appeared. - **Keep Platform projections report-driven.** Existing terminal job/result projection can remain for explicit lifecycle commands, but generated Run startup state must converge through Run reports rather than Platform's stale stored state or registration-time probes. +- **Report autonomous execution as observed facts, not job completions.** Run submits terminal autonomous lifecycle results through a signed lifecycle report route. Platform verifies the report belongs to the bound server/run session, then reuses lifecycle projection logic without manufacturing a platform job lease. - **Preserve builder security boundaries.** The platform-owned builder receives the plaintext component auth key internally and the autonomous plan as build input. Machine-side run endpoints still cannot claim `distribution.build` jobs or fetch plaintext build input. ## Risks / Trade-offs -- **Run repository lag** -> The generated package can carry the plan before the independent Run executable consumes it. Mitigation: protocol and build input are explicit, and this repo does not re-add Run source. +- **Cross-repository release skew** -> Platform can build packages with the plan before every deployed Run binary has the autonomous executor. Mitigation: generated Run logs explicit plan load/skip/failure states, tests cover the independent `run` repository, and this repo still does not own the `run/` source tree. - **Stored Platform state may look stale until Run reports** -> Registration no longer paper-over probes with Platform jobs. Mitigation: UI/API must treat persisted lifecycle state as projection, not observed process truth. - **Plan drift between build and execution** -> A package carries the plugin declarations and deployment revision available at build time. Mitigation: include plugin version, profile key, deployment revision, and target release so Run and Platform can report stale-plan evidence. - **Operator command APIs still dispatch jobs** -> This change fixes generated Run autonomous startup first. Explicit commands remain auditable Platform requests until a later change redesigns command transport around Run-owned intent handling. @@ -38,6 +41,8 @@ The corrected ownership model is: Platform builds and authenticates a server-sco - Stop queuing registration-time lifecycle/status jobs for generated Runs. - Extend run distribution build input and DTOs with `autonomousLifecycle` for Run packages only. - Update platform builder input materialization so the generated package has a serialized plan alongside the existing workspace seed. +- Add Platform's signed lifecycle report endpoint for Run-owned bootstrap results. +- Update the independent Run repository to load, validate, execute, and report the autonomous plan using generic lifecycle capabilities. - Update service tests to assert registration does not enqueue bootstrap/reconciliation jobs and build input includes the plan. ## Open Questions diff --git a/openspec/changes/make-run-autonomous-lifecycle-owner/specs/run-autonomous-lifecycle-owner/spec.md b/openspec/changes/make-run-autonomous-lifecycle-owner/specs/run-autonomous-lifecycle-owner/spec.md index cbaaada..8a95109 100644 --- a/openspec/changes/make-run-autonomous-lifecycle-owner/specs/run-autonomous-lifecycle-owner/spec.md +++ b/openspec/changes/make-run-autonomous-lifecycle-owner/specs/run-autonomous-lifecycle-owner/spec.md @@ -11,6 +11,10 @@ Generated Run packages SHALL carry a bounded autonomous lifecycle plan that lets - **WHEN** the generated Run executable starts on its host - **THEN** Run can read the embedded autonomous lifecycle plan and execute plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally +#### Scenario: Generated Run reports autonomous bootstrap result +- **WHEN** Run completes an autonomous lifecycle bootstrap action from the embedded plan +- **THEN** Run reports the terminal lifecycle result to Platform without requiring a Platform job claim, acknowledgement, lease token, or job result + ### Requirement: Platform follows Run-reported lifecycle facts Platform SHALL treat persisted server lifecycle state as a projection of Run-reported lifecycle facts, heartbeats, logs, and terminal process reports rather than as authoritative observed process truth. @@ -22,6 +26,10 @@ Platform SHALL treat persisted server lifecycle state as a projection of Run-rep - **WHEN** Run reports that the managed process is running - **THEN** Platform projects the server as running based on the Run report +#### Scenario: Run reports through the signed lifecycle report channel +- **WHEN** a registered Run submits a terminal lifecycle report for its bound server instance +- **THEN** Platform validates the active Run session and server binding, records audit evidence, and updates the visible server projection from the reported process facts + ### Requirement: Plugins declare game-specific lifecycle behavior Plugins SHALL declare lifecycle action refs, dependency probes, install plans, runtime profiles, log sources, and deployment templates needed by Run, and Platform SHALL NOT hardcode game-specific install, update, status, or startup behavior. diff --git a/openspec/changes/make-run-autonomous-lifecycle-owner/tasks.md b/openspec/changes/make-run-autonomous-lifecycle-owner/tasks.md index ed426c4..fe69318 100644 --- a/openspec/changes/make-run-autonomous-lifecycle-owner/tasks.md +++ b/openspec/changes/make-run-autonomous-lifecycle-owner/tasks.md @@ -14,9 +14,16 @@ - [x] 2.1 Add autonomous lifecycle plan domain/build-input structures without exposing the plan through machine job-channel DTOs. - [x] 2.2 Populate the plan from plugin lifecycle declarations, runtime profile data, dependency declarations, log sources, DLL extensions, and deployment definition. - [x] 2.3 Stop generated Run registration from enqueueing bootstrap start or status reconciliation jobs. -- [x] 2.4 Update protocol and governance docs to make Run the lifecycle authority. +- [x] 2.4 Add a signed Platform lifecycle report endpoint that projects server state from Run-owned terminal lifecycle facts without creating jobs. +- [x] 2.5 Update protocol and governance docs to make Run the lifecycle authority. -## 3. Verification +## 3. Independent Run Implementation -- [x] 3.1 Update service tests for autonomous build input and no registration-time lifecycle dispatch. -- [x] 3.2 Run targeted Go tests plus repository structure checks. +- [x] 3.1 Add generic autonomous lifecycle plan protocol types and validation in the independent `run` repository. +- [x] 3.2 Load `.platform/autonomous-lifecycle-plan.json` from the generated package workspace, validate package identity/target, run dependency probes/install plans, execute the bootstrap action, and report the terminal lifecycle result to Platform. +- [x] 3.3 Keep Run generic: no SCUM executable names, Steam app IDs, install paths, ports, or game-specific branches. + +## 4. Verification + +- [x] 4.1 Update service/API/runtime tests for autonomous build input, no registration-time lifecycle dispatch, Run-owned bootstrap execution, lifecycle reporting, and signed report routing. +- [x] 4.2 Run full Go tests for `platform/` and the independent `run` repository plus repository structure checks. diff --git a/platform/api/authorization_test.go b/platform/api/authorization_test.go index ccf3179..f7e134e 100644 --- a/platform/api/authorization_test.go +++ b/platform/api/authorization_test.go @@ -142,6 +142,7 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) { assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized) privateUpdateBodies := map[string]any{ + "/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}}, "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, "/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1}, "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, diff --git a/platform/api/control_handlers_test.go b/platform/api/control_handlers_test.go index 1a2406b..78659fd 100644 --- a/platform/api/control_handlers_test.go +++ b/platform/api/control_handlers_test.go @@ -42,6 +42,28 @@ func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) { } } +func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) { + router := newTestRouter() + adminSession := createAdminSession(t, router) + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) + helloRequest := validRunControlHelloRequest() + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read") + helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-report" + hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) + server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession) + + recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}}) + assertStatus(t, recorder, http.StatusOK) + response := decodeBody[dto.RunLifecycleReportResponse](t, recorder) + if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning { + t.Fatalf("expected lifecycle report projection, got %+v", response) + } + updated := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+server.ID, adminSession) + if updated.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected server state projected running, got %+v", updated) + } +} + func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) { router := newTestRouter() first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 451d87d..1e78ed8 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -120,6 +120,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { 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.requireRunSignature(h.runControlHeartbeat)) + mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport)) 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)) @@ -1697,6 +1698,35 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result)) } +// runLifecycleReport godoc +// @Summary Report autonomous run lifecycle result +// @Description Lets a registered run endpoint report an observed lifecycle terminal result without a platform-assigned job lease. +// @Tags run +// @Accept json +// @Produce json +// @Param body body dto.RunLifecycleReportRequest true "Run lifecycle report request" +// @Success 200 {object} dto.RunLifecycleReportResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/lifecycle/report [post] +func (h *coreHandlers) runLifecycleReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunLifecycleReportRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ReportRunLifecycle(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunLifecycleReportFromDomain(result)) +} + // runJobClaim godoc // @Summary Claim one run job // @Description Lets a registered run endpoint claim one queued job assigned to it using the active session token. diff --git a/platform/domain/control.go b/platform/domain/control.go index 623f45c..186cefa 100644 --- a/platform/domain/control.go +++ b/platform/domain/control.go @@ -53,6 +53,26 @@ type RunControlHeartbeatResult struct { ServerTime time.Time } +type RunLifecycleReport struct { + RunEndpointID string + SessionToken string + ServerInstanceID string + Capability string + State JobState + Progress RunJobProgressReport + Message string + ErrorCode string + ExecutionResult JobExecutionResult +} + +type RunLifecycleReportResult struct { + Accepted bool + RunEndpointID string + ServerInstanceID string + ProjectedState ServerInstanceState + ServerTime time.Time +} + type RunControlSession struct { RunEndpointID string SessionToken string `json:"-"` @@ -103,6 +123,16 @@ func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlH return result } +func CopyRunLifecycleReport(report RunLifecycleReport) RunLifecycleReport { + report.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(report.ExecutionResult.ServerDeploymentEvidence) + report.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(report.ExecutionResult.DeploymentReceipt) + return report +} + +func CopyRunLifecycleReportResult(result RunLifecycleReportResult) RunLifecycleReportResult { + return result +} + func CopyRunControlSession(session RunControlSession) RunControlSession { session.UsedNonces = CopyStringSlice(session.UsedNonces) return session diff --git a/platform/dto/control.go b/platform/dto/control.go index 3c31b09..68ee8c1 100644 --- a/platform/dto/control.go +++ b/platform/dto/control.go @@ -57,6 +57,26 @@ type RunControlHeartbeatResponse struct { ServerTime time.Time `json:"serverTime"` } +type RunLifecycleReportRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + ServerInstanceID string `json:"serverInstanceId"` + Capability string `json:"capability"` + State domain.JobState `json:"state"` + Progress JobProgressBody `json:"progress"` + Message string `json:"message,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ExecutionResult RunJobExecutionResultBody `json:"executionResult,omitempty"` +} + +type RunLifecycleReportResponse struct { + Accepted bool `json:"accepted"` + RunEndpointID string `json:"runEndpointId"` + ServerInstanceID string `json:"serverInstanceId"` + ProjectedState domain.ServerInstanceState `json:"projectedState,omitempty"` + ServerTime time.Time `json:"serverTime"` +} + func (request RunControlHelloRequest) ToDomain() domain.RunControlHello { return domain.RunControlHello{ RegistrationToken: request.RegistrationToken, @@ -92,6 +112,20 @@ func (request RunControlHeartbeatRequest) ToDomain() domain.RunControlHeartbeat } } +func (request RunLifecycleReportRequest) ToDomain() domain.RunLifecycleReport { + return domain.RunLifecycleReport{ + RunEndpointID: request.RunEndpointID, + SessionToken: request.SessionToken, + ServerInstanceID: request.ServerInstanceID, + Capability: request.Capability, + State: request.State, + Progress: progressReportToDomain(request.Progress), + Message: request.Message, + ErrorCode: request.ErrorCode, + 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, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, + } +} + func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHelloResponse { result = domain.CopyRunControlHelloResult(result) return RunControlHelloResponse{ @@ -114,3 +148,8 @@ func RunControlHeartbeatFromDomain(result domain.RunControlHeartbeatResult) RunC ServerTime: result.ServerTime, } } + +func RunLifecycleReportFromDomain(result domain.RunLifecycleReportResult) RunLifecycleReportResponse { + result = domain.CopyRunLifecycleReportResult(result) + return RunLifecycleReportResponse{Accepted: result.Accepted, RunEndpointID: result.RunEndpointID, ServerInstanceID: result.ServerInstanceID, ProjectedState: result.ProjectedState, ServerTime: result.ServerTime} +} diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index e823300..6c5c681 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -63,6 +63,8 @@ Platform-owned Run distribution builds embed an autonomous lifecycle plan for th The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan. +Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records audit evidence, and projects server state from Run-reported process facts without creating or completing a Platform job. + ## Log Ingest Implemented HTTP JSON routes: diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 7439c42..bbffc63 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -432,6 +432,38 @@ func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testi } } +func TestCoreServiceRunLifecycleReportProjectsGeneratedRunFacts(t *testing.T) { + svc := newTestCoreService() + plugin := createGeneratedRunStatusPlugin(t, svc) + instance := domain.ServerInstance{ID: "managed-autonomous-start", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-autonomous-start"), Name: "Managed Autonomous Start", State: domain.ServerInstanceStateDraft, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "run-local", ServerRoot: `D:\scum-autonomous`, Revision: 1}} + if err := svc.store.ServerInstances().Create(instance); err != nil { + t.Fatalf("create autonomous server: %v", err) + } + registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID) + + reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}}) + if err != nil || !reported.Accepted || reported.ProjectedState != domain.ServerInstanceStateRunning { + t.Fatalf("expected accepted lifecycle report projected running, result=%+v err=%v", reported, err) + } + stored, err := svc.GetServerInstance(instance.ID) + if err != nil || stored.State != domain.ServerInstanceStateRunning { + t.Fatalf("expected Run report to project server running, server=%+v err=%v", stored, err) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil || len(jobs) != 0 { + t.Fatalf("autonomous lifecycle report must not create platform jobs, jobs=%+v err=%v", jobs, err) + } + + other := domain.ServerInstance{ID: "managed-autonomous-other", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-autonomous-other"), Name: "Managed Autonomous Other", State: domain.ServerInstanceStateDraft, ConfigVersion: 1} + if err := svc.store.ServerInstances().Create(other); err != nil { + t.Fatalf("create other server: %v", err) + } + _, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: other.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}) + if err == nil || !strings.Contains(err.Error(), "runEndpointId must match server instance") { + t.Fatalf("expected report for another server binding to be rejected, err=%v", err) + } +} + func TestCoreServiceGeneratedRunRegistrationDoesNotDispatchStatusReconciliation(t *testing.T) { svc := newTestCoreService() plugin := createGeneratedRunStatusPlugin(t, svc) diff --git a/platform/service/resources.go b/platform/service/resources.go index 9483734..99a3a2a 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -139,6 +139,7 @@ type Core interface { AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) + ReportRunLifecycle(domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index 44be18b..8b4e0da 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -2,13 +2,65 @@ package service import ( "strings" - "time" "browser.local/platform/domain" "browser.local/platform/validator" ) +func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) { + report = domain.CopyRunLifecycleReport(report) + if err := validator.ValidateRunLifecycleReport(report); err != nil { + return domain.RunLifecycleReportResult{}, err + } + if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil { + return domain.RunLifecycleReportResult{}, err + } + instance, err := svc.store.ServerInstances().Get(report.ServerInstanceID) + if err != nil { + return domain.RunLifecycleReportResult{}, err + } + if instance.RunEndpointID != report.RunEndpointID { + return domain.RunLifecycleReportResult{}, validationError("runEndpointId must match server instance") + } + if instance.State == domain.ServerInstanceStateDeleted { + return domain.RunLifecycleReportResult{}, validationError("server instance must not be deleted") + } + + stamp := svc.now() + nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult) + if projected { + instance.State = nextState + instance.UpdatedAt = stamp + if err := validator.ValidateServerInstance(instance); err != nil { + return domain.RunLifecycleReportResult{}, err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return domain.RunLifecycleReportResult{}, err + } + } + auditResult := domain.AuditResultSuccess + if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled { + auditResult = domain.AuditResultFailed + } + if err := svc.recordAuditEvent("run:"+report.RunEndpointID, "lifecycle.report", "server-instance", instance.ID, auditResult, lifecycleReportSummary(report, nextState, projected)); err != nil { + return domain.RunLifecycleReportResult{}, err + } + return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil +} + +func lifecycleReportSummary(report domain.RunLifecycleReport, projectedState domain.ServerInstanceState, projected bool) string { + for _, candidate := range []string{report.ExecutionResult.AuditSummary, report.Progress.Message, report.Message, report.ErrorCode} { + if strings.TrimSpace(candidate) != "" { + return candidate + } + } + if projected { + return "run reported " + report.Capability + " " + string(report.State) + "; projected server state " + string(projectedState) + } + return "run reported " + report.Capability + " " + string(report.State) +} + func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error { if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) { return nil diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index 153fc86..c57082d 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -54,6 +54,30 @@ func ValidateRunJobResult(result domain.RunJobResult) error { return finish(violations) } +func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", report.RunEndpointID) + violations = appendRequired(violations, "sessionToken", report.SessionToken) + violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID) + violations = appendRequired(violations, "capability", report.Capability) + if !validLifecycleReportCapability(report.Capability) { + violations = append(violations, "capability must be process.install, process.start, process.stop, or process.status") + } + if !validTerminalJobState(report.State) { + violations = append(violations, "state must be succeeded, failed, or cancelled") + } + violations = appendProgressViolations(violations, report.Progress) + violations = appendMessageLength(violations, "message", report.Message) + violations = appendMessageLength(violations, "errorCode", report.ErrorCode) + if len([]byte(report.ExecutionResult.Content)) > maxJobChannelMessageLength*256 { + violations = append(violations, "executionResult.content is too large") + } + if report.ExecutionResult.Checksum != "" && !validSHA256Checksum(report.ExecutionResult.Checksum) { + violations = append(violations, "executionResult.checksum must be sha256:") + } + return finish(violations) +} + func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInputRequest) error { var violations []string violations = appendLeaseFields(violations, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) @@ -159,7 +183,7 @@ func appendProgressViolations(violations []string, progress domain.RunJobProgres func validDeploymentProgressPhase(phase string) bool { switch phase { - case "queued", "claimed", "preflight", "install", "configure", "start", "health": + case "queued", "claimed", "preflight", "install", "configure", "start", "stop", "status", "health": return true default: return false @@ -181,3 +205,12 @@ func validTerminalJobState(state domain.JobState) bool { return false } } + +func validLifecycleReportCapability(capability string) bool { + switch capability { + case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus: + return true + default: + return false + } +}