diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index ab923cc..9281c47 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -166,7 +166,7 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden) ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession) - if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || !ownerMetrics.Items[0].Online || ownerMetrics.Items[0].CPUPercent != nil || ownerMetrics.Items[0].Source != "run-metrics-pending" { + if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || ownerMetrics.Items[0].Online || ownerMetrics.Items[0].CPUPercent != nil || ownerMetrics.Items[0].Source != "run-metrics-pending" { t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) } otherMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", otherSession) diff --git a/platform/domain/resources.go b/platform/domain/resources.go index e7d3a7f..9dfa3f7 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -632,9 +632,10 @@ type GamePluginManifestRegistration struct { } type PluginAssetFile struct { - Path string - Content string - Mode int + Path string + Content string + Encoding string + Mode int } type GamePlugin struct { diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 77a25b2..e788016 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -402,9 +402,10 @@ type GamePluginManifestRegistrationRequest struct { } type PluginAssetFileBody struct { - Path string `json:"path"` - Content string `json:"content,omitempty"` - Mode int `json:"mode,omitempty"` + Path string `json:"path"` + Content string `json:"content,omitempty"` + Encoding string `json:"encoding,omitempty"` + Mode int `json:"mode,omitempty"` } type GamePluginCreateRequest struct { @@ -1039,7 +1040,7 @@ func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetF } out := make([]domain.PluginAssetFile, len(files)) for i, file := range files { - out[i] = domain.PluginAssetFile{Path: file.Path, Content: file.Content, Mode: file.Mode} + out[i] = domain.PluginAssetFile{Path: file.Path, Content: file.Content, Encoding: file.Encoding, Mode: file.Mode} } return out } diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 039bb86..1833a3b 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -86,7 +86,7 @@ func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) { AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}}, }, AssetFiles: []PluginAssetFileBody{ - {Path: "actions/install.json", Content: "{}", Mode: 0o600}, + {Path: "actions/install.json", Content: "{}", Encoding: "base64", Mode: 0o600}, }, } @@ -98,7 +98,7 @@ func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) { domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke" domainRegistration.Manifest.AI.Purposes[0] = "config.suggest" - if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.AssetFiles[0].Path != "actions/install.json" || request.AssetFiles[0].Content != "{}" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { + if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.AssetFiles[0].Path != "actions/install.json" || request.AssetFiles[0].Content != "{}" || request.AssetFiles[0].Encoding != "base64" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" { t.Fatalf("expected manifest request slices to be copied, got %+v", request) } } diff --git a/platform/service/client_manager_lifecycle.go b/platform/service/client_manager_lifecycle.go index d4a0813..72814ac 100644 --- a/platform/service/client_manager_lifecycle.go +++ b/platform/service/client_manager_lifecycle.go @@ -461,7 +461,7 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } @@ -1027,11 +1027,11 @@ func (svc *CoreService) clientManagerLifecycleView(installation domain.ClientMan } profile, _ := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) - view.Actions = clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr) + view.Actions = svc.clientManagerLifecycleActions(installation, view.Distribution, profile, endpoint, endpointErr) return domain.CopyClientManagerLifecycleView(view), nil } -func clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability { +func (svc *CoreService) clientManagerLifecycleActions(installation domain.ClientManagerInstallation, distribution domain.ClientManagerDistribution, profile domain.RuntimeClientManagerProfile, endpoint domain.RunEndpoint, endpointErr error) []domain.ClientManagerLifecycleActionAvailability { operations := []domain.ClientManagerLifecycleOperation{domain.ClientManagerOperationDeploy, domain.ClientManagerOperationStart, domain.ClientManagerOperationStop, domain.ClientManagerOperationRestart, domain.ClientManagerOperationStatus, domain.ClientManagerOperationUpdate, domain.ClientManagerOperationRollback, domain.ClientManagerOperationUninstall} items := make([]domain.ClientManagerLifecycleActionAvailability, 0, len(operations)) for _, operation := range operations { @@ -1046,7 +1046,7 @@ func clientManagerLifecycleActions(installation domain.ClientManagerInstallation case domain.ClientManagerOperationUninstall: capability = domain.JobCapabilityClientManagerUninstall } - available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability) + available := endpointErr == nil && endpoint.Status == domain.RunEndpointStatusOnline && svc.runEndpointHeartbeatCurrent(endpoint) && containsString(endpoint.Capabilities, capability) && containsString(profile.Deployment.RequiredRunCapabilities, capability) reason := "" if !available { reason = "assigned Run endpoint is offline or lacks the declared capability" diff --git a/platform/service/dependency_updates.go b/platform/service/dependency_updates.go index f008934..c62dbcd 100644 --- a/platform/service/dependency_updates.go +++ b/platform/service/dependency_updates.go @@ -270,7 +270,7 @@ func (svc *CoreService) resolveDependencyContext(serverInstanceID string) (depen 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) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return dependencyResolution{}, err } @@ -410,7 +410,7 @@ func (svc *CoreService) resolveRunUpdate(job domain.Job) (domain.RunUpdateJob, d 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) + endpoint, err := svc.GetRunEndpoint(job.RunEndpointID) if err != nil { return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err } @@ -551,7 +551,7 @@ func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthRepor 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) + endpoint, err := svc.GetRunEndpoint(report.RunEndpointID) if err != nil { return domain.RunUpdateHealthResult{}, err } diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index e0e6e08..6556ed8 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -376,13 +376,14 @@ func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) { return "", nil } type seedFile struct { - Path string `json:"path"` - Content string `json:"content"` - Mode int `json:"mode,omitempty"` + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding,omitempty"` + Mode int `json:"mode,omitempty"` } seed := make([]seedFile, len(files)) for i, file := range files { - seed[i] = seedFile{Path: file.Path, Content: file.Content, Mode: file.Mode} + seed[i] = seedFile{Path: file.Path, Content: file.Content, Encoding: file.Encoding, Mode: file.Mode} } body, err := json.Marshal(seed) if err != nil { diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index 4a05ed1..90903c1 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -95,6 +95,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { plugin.LifecycleAssets = []domain.PluginAssetFile{ {Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, + {Path: "assets/map.bin", Content: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00, 0x7f}), Encoding: "base64", Mode: 0o600}, } plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources, domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14}, @@ -135,10 +136,10 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { if err := json.Unmarshal(decodedSeed, &seedFiles); err != nil { t.Fatalf("unmarshal workspace seed: %v", err) } - if platformInput.ProfileKey != "local" || len(seedFiles) != 3 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" { + if platformInput.ProfileKey != "local" || len(seedFiles) != 4 || seedFiles[1].Path != "bin/install-server" || seedFiles[1].Content == "" || seedFiles[2].Encoding != "base64" { t.Fatalf("platform builder received incomplete plugin workspace seed: profile=%q seed=%+v", platformInput.ProfileKey, seedFiles) } - if seedFiles[2].Path != ".platform/autonomous-lifecycle-plan.json" || seedFiles[2].Content == "" || seedFiles[2].Mode != 0o600 { + if seedFiles[3].Path != ".platform/autonomous-lifecycle-plan.json" || seedFiles[3].Content == "" || seedFiles[3].Mode != 0o600 { t.Fatalf("workspace seed did not include autonomous lifecycle plan file: %+v", seedFiles) } plan := platformInput.AutonomousLifecycle @@ -152,7 +153,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { t.Fatalf("autonomous lifecycle plan must not carry file-tail sources into process.start: %+v", plan.LogSources) } var seededPlan domain.RunAutonomousLifecyclePlan - if err := json.Unmarshal([]byte(seedFiles[2].Content), &seededPlan); err != nil { + if err := json.Unmarshal([]byte(seedFiles[3].Content), &seededPlan); err != nil { t.Fatalf("unmarshal seeded autonomous lifecycle plan: %v", err) } if seededPlan.ServerInstanceID != plan.ServerInstanceID || seededPlan.Bootstrap == nil || seededPlan.Bootstrap.TargetKey != plan.Bootstrap.TargetKey { diff --git a/platform/service/distributions.go b/platform/service/distributions.go index da20b1f..87e0a2d 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -413,7 +413,7 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv if err != nil { return domain.ServerRuntimeActions{}, err } - endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, endpointErr := svc.GetRunEndpoint(instance.RunEndpointID) runRegistered := endpointErr == nil if endpointErr != nil && !errors.Is(endpointErr, repo.ErrNotFound) { return domain.ServerRuntimeActions{}, endpointErr @@ -514,7 +514,7 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain if request.Checksum != artifact.Checksum { return domain.RunUpdateJob{}, validationError("checksum must match artifact") } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.RunUpdateJob{}, err } diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 25ee09f..014be8d 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -199,10 +199,25 @@ func TestCoreServiceDistributionBuildIgnoresStaleRunEndpoint(t *testing.T) { if err != nil { t.Fatalf("get runtime actions: %v", err) } + if actions.RunStatus != domain.RunEndpointStatusOffline { + t.Fatalf("stale Run endpoint must project offline runtime status, got %+v", actions) + } + staleRunGated := map[string]bool{"dependencies-check": false, "dependencies-install": false, "live-logs": false} for _, action := range actions.Actions { if action.Key == "generate-run" && !action.Available { t.Fatalf("stale Run endpoint must not gate platform build availability: %+v", action) } + if _, gated := staleRunGated[action.Key]; gated { + staleRunGated[action.Key] = true + if action.Available || action.Reason != "Run heartbeat has not been observed" { + t.Fatalf("stale Run endpoint must gate runtime action %s, got %+v", action.Key, action) + } + } + } + for key, seen := range staleRunGated { + if !seen { + t.Fatalf("expected stale Run gating action %s in %+v", key, actions.Actions) + } } } diff --git a/platform/service/plugin_operations.go b/platform/service/plugin_operations.go index 65f3057..d8fdd16 100644 --- a/platform/service/plugin_operations.go +++ b/platform/service/plugin_operations.go @@ -55,7 +55,7 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d if !containsString(plugin.ProductionLifecycle.Operations, string(request.Operation)) { return domain.PluginLifecycleResult{}, validationError("plugin lifecycle operation is not declared by the manifest") } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.PluginLifecycleResult{}, err } diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go index 5424877..a9d427f 100644 --- a/platform/service/remote_adapters.go +++ b/platform/service/remote_adapters.go @@ -17,7 +17,7 @@ func (svc *CoreService) ListRemoteAdapterDeclarationsForSession(sessionID string if err != nil { return nil, err } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request } if selected.Key == "" { plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID) - endpoint, endpointErr := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, endpointErr := svc.GetRunEndpoint(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} } diff --git a/platform/service/resources.go b/platform/service/resources.go index e794a22..20d0e9f 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -739,17 +739,67 @@ func (svc *CoreService) RegisterGamePluginManifest(registration domain.GamePlugi if err := validator.ValidateGamePlugin(plugin); err != nil { return domain.GamePlugin{}, err } - if err := svc.store.GamePlugins().Create(plugin); err != nil { - if !errors.Is(err, repo.ErrDuplicate) { - return domain.GamePlugin{}, err - } + if existing, err := svc.store.GamePlugins().Get(plugin.ID); err == nil { if err := svc.store.GamePlugins().Update(plugin); err != nil { return domain.GamePlugin{}, err } + if err := svc.refreshServerPluginReferences(existing.ID, plugin.Version); err != nil { + return domain.GamePlugin{}, err + } + } else if errors.Is(err, repo.ErrNotFound) { + if err := svc.store.GamePlugins().Create(plugin); err != nil { + return domain.GamePlugin{}, err + } + } else { + return domain.GamePlugin{}, err } return domain.CopyGamePlugin(plugin), nil } +// refreshServerPluginReferences keeps existing server projections usable when a +// manifest is refreshed in place. The server and its logical runtime binding +// carry the manifest version used for lifecycle validation; leaving either at a +// stale version would make a healthy existing server impossible to start after +// the registry refresh. +func (svc *CoreService) refreshServerPluginReferences(pluginID, pluginVersion string) error { + if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(pluginVersion) == "" { + return nil + } + stamp := svc.now() + instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{PluginID: pluginID}) + if err != nil { + return err + } + for _, instance := range instances { + if instance.PluginVersion != pluginVersion { + instance.PluginVersion = pluginVersion + instance.UpdatedAt = stamp + if err := validator.ValidateStoredServerInstance(instance); err != nil { + return err + } + if err := svc.store.ServerInstances().Update(instance); err != nil { + return err + } + } + + bindings, err := svc.store.RuntimeBindings().List(domain.RuntimeBindingFilter{ServerInstanceID: instance.ID}) + if err != nil { + return err + } + for _, binding := range bindings { + if binding.PluginID != pluginID || binding.PluginVersion == pluginVersion { + continue + } + binding.PluginVersion = pluginVersion + binding.UpdatedAt = stamp + if err := svc.store.RuntimeBindings().Update(binding); err != nil { + return err + } + } + } + return nil +} + func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRegistration) domain.GamePlugin { registration = domain.CopyGamePluginManifestRegistration(registration) manifest := registration.Manifest @@ -1633,7 +1683,7 @@ func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (do } var endpoint domain.RunEndpoint if strings.TrimSpace(instance.RunEndpointID) != "" { - endpoint, err = svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err = svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.ServerInstance{}, fmt.Errorf("get run endpoint dependency: %w", err) } @@ -1779,7 +1829,7 @@ func (svc *CoreService) GetPlatformResourceUsage() (domain.PlatformResourceUsage if err != nil { return domain.PlatformResourceUsage{}, err } - endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{}) + endpoints, err := svc.ListRunEndpoints(domain.RunEndpointFilter{}) if err != nil { return domain.PlatformResourceUsage{}, err } @@ -2212,7 +2262,7 @@ func (svc *CoreService) latestMetricsForServer(instance domain.ServerInstance) d } return domain.ServerMetrics{ ServerInstanceID: instance.ID, - Online: instance.State == domain.ServerInstanceStateRunning, + Online: false, Source: "run-metrics-pending", CollectedAt: svc.now(), } diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 1e4c1d5..315bad3 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/base64" "errors" "strings" "testing" @@ -165,6 +166,17 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) { } +func TestCoreServiceRejectsServerCreationOnStaleRunEndpoint(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + svc.now = func() time.Time { return fixedTime.Add(runHeartbeatStaleAfter + time.Second) } + + _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-stale-run", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Stale Run Server"}) + if err == nil || !strings.Contains(err.Error(), "get run endpoint dependency") || !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected stale Run endpoint to be rejected before binding, got %v", err) + } +} + func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) @@ -685,7 +697,7 @@ func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) { if err != nil { t.Fatalf("list owner metrics: %v", err) } - if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || !ownerMetrics[0].Online || ownerMetrics[0].CPUPercent != nil || ownerMetrics[0].Source != "run-metrics-pending" { + if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || ownerMetrics[0].Online || ownerMetrics[0].CPUPercent != nil || ownerMetrics[0].Source != "run-metrics-pending" { t.Fatalf("expected pending metrics without fabricated resource values, got %+v", ownerMetrics) } @@ -1223,8 +1235,8 @@ func TestCoreServiceRegistersGamePluginManifest(t *testing.T) { svc := newTestCoreService() registration := validPluginManifestRegistration() - registration.Manifest.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}} - registration.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n"}} + registration.Manifest.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}, {Path: "assets/map.bin", Mode: 0o600}} + registration.AssetFiles = []domain.PluginAssetFile{{Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n"}, {Path: "assets/map.bin", Content: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00, 0x7f}), Encoding: "base64"}} plugin, err := svc.RegisterGamePluginManifest(registration) if err != nil { t.Fatalf("register manifest: %v", err) @@ -1247,7 +1259,7 @@ func TestCoreServiceRegistersGamePluginManifest(t *testing.T) { if len(plugin.BridgeActions) != 4 || plugin.BridgeActions[0] != string(domain.PluginBridgeActionServerInstancesRead) { t.Fatalf("expected bridge actions, got %+v", plugin.BridgeActions) } - if len(plugin.LifecycleAssets) != 2 || plugin.LifecycleAssets[1].Path != "bin/install-server" || plugin.LifecycleAssets[1].Mode != 0o700 { + if len(plugin.LifecycleAssets) != 3 || plugin.LifecycleAssets[1].Path != "bin/install-server" || plugin.LifecycleAssets[1].Mode != 0o700 || plugin.LifecycleAssets[2].Encoding != "base64" { t.Fatalf("expected declared lifecycle assets with manifest mode defaults, got %+v", plugin.LifecycleAssets) } @@ -1624,6 +1636,12 @@ func TestCoreServiceUpsertsDuplicateGamePluginManifest(t *testing.T) { if _, err := svc.RegisterGamePluginManifest(registration); err != nil { t.Fatalf("register first manifest: %v", err) } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: "manifest-refresh-server", PluginID: registration.Manifest.ID, PluginVersion: registration.Manifest.Version, Name: "Manifest refresh server"}); err != nil { + t.Fatalf("create server for manifest refresh: %v", err) + } + if err := svc.store.RuntimeBindings().Create(domain.RuntimeBinding{ID: "runtime-binding-manifest-refresh-server", ServerInstanceID: "manifest-refresh-server", PluginID: registration.Manifest.ID, PluginVersion: registration.Manifest.Version, ProfileKey: "local", Mode: "local-process", Status: domain.RuntimeBindingStatusComplete, CreatedAt: fixedTime, UpdatedAt: fixedTime}); err != nil { + t.Fatalf("create runtime binding for manifest refresh: %v", err) + } registration.Manifest.Version = "0.1.1" registration.Manifest.Description = "Development plugin refreshed" @@ -1639,6 +1657,14 @@ func TestCoreServiceUpsertsDuplicateGamePluginManifest(t *testing.T) { if err != nil || len(listed) != 1 || listed[0].ID != "game.example" || listed[0].Version != "0.1.1" { t.Fatalf("expected one refreshed plugin after upsert, listed=%+v err=%v", listed, err) } + server, err := svc.GetServerInstance("manifest-refresh-server") + if err != nil || server.PluginVersion != "0.1.1" { + t.Fatalf("expected existing server to follow refreshed plugin version, server=%+v err=%v", server, err) + } + binding, err := svc.store.RuntimeBindings().Get("runtime-binding-manifest-refresh-server") + if err != nil || binding.PluginVersion != "0.1.1" { + t.Fatalf("expected runtime binding to follow refreshed plugin version, binding=%+v err=%v", binding, err) + } } func TestCoreServiceRejectsUnsafeGamePluginManifest(t *testing.T) { diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index f589799..9038f39 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -104,7 +104,7 @@ func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleComma if strings.TrimSpace(instance.RunEndpointID) == "" { return domain.ServerLifecycleResult{}, validationError("an active Run heartbeat is required for legacy manual deployment dispatch") } - if _, err := svc.store.RunEndpoints().Get(instance.RunEndpointID); err != nil { + if _, err := svc.GetRunEndpoint(instance.RunEndpointID); err != nil { return domain.ServerLifecycleResult{}, err } plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID) diff --git a/platform/service/server_lifecycle.go b/platform/service/server_lifecycle.go index cd54cd2..54b0de5 100644 --- a/platform/service/server_lifecycle.go +++ b/platform/service/server_lifecycle.go @@ -66,7 +66,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc instance.ConfigChecksum = validator.BytesChecksum([]byte(instance.ConfigContent)) instance.ConfigUpdatedAt = stamp if strings.TrimSpace(create.DeploymentTargetID) != "" { - target, targetErr := svc.store.RunEndpoints().Get(create.DeploymentTargetID) + target, targetErr := svc.GetRunEndpoint(create.DeploymentTargetID) if targetErr != nil { return domain.ServerLifecycleResult{}, fmt.Errorf("get deployment target dependency: %w", targetErr) } @@ -106,7 +106,7 @@ func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecyc } return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{Accepted: true, Action: domain.ServerLifecycleActionCreate, Instance: instance}), nil } - endpoint, err := svc.store.RunEndpoints().Get(create.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(create.RunEndpointID) if err != nil { return domain.ServerLifecycleResult{}, fmt.Errorf("get run endpoint dependency: %w", err) } @@ -296,7 +296,7 @@ func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID str if err != nil { return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get plugin dependency: %w", err) } - endpoint, err := svc.store.RunEndpoints().Get(runEndpointID) + endpoint, err := svc.GetRunEndpoint(runEndpointID) if err != nil { return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get run endpoint dependency: %w", err) } @@ -335,7 +335,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act var dllExtensions []domain.RuntimeDLLExtensionPlan var logSources []domain.RuntimeLogSource if action == domain.ServerLifecycleActionStart && hasProfile { - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return domain.Job{}, err } diff --git a/platform/service/source_rcon.go b/platform/service/source_rcon.go index a05df71..8faca63 100644 --- a/platform/service/source_rcon.go +++ b/platform/service/source_rcon.go @@ -167,7 +167,7 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) { return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare SCUM RCON command access") } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) + endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return sourceRCONDispatchResolution{}, err } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 85b4a22..3ec3f4b 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1,10 +1,13 @@ package validator import ( + "bytes" + "encoding/base64" "fmt" "regexp" "strconv" "strings" + "unicode/utf8" "browser.local/platform/domain" ) @@ -260,6 +263,9 @@ func validatePluginAssetFileDeclarations(prefix string, files []domain.PluginAss if file.Content != "" { violations = append(violations, field+".content must be supplied only in registration assetFiles") } + if file.Encoding != "" { + violations = append(violations, field+".encoding must be supplied only in registration assetFiles") + } if file.Mode != 0 && file.Mode != 0o600 && file.Mode != 0o700 { violations = append(violations, field+".mode is unsafe") } @@ -282,7 +288,10 @@ func validatePluginAssetFiles(prefix string, files []domain.PluginAssetFile) []s violations = append(violations, field+".path is duplicated") } seen[file.Path] = struct{}{} - if len([]byte(file.Content)) > 64*1024 || strings.ContainsRune(file.Content, '\x00') || containsUnsafeRuntimeSecret(file.Content) { + content, err := pluginAssetContentBytes(file) + if err != nil { + violations = append(violations, field+err.Error()) + } else if len(content) > 64*1024 || pluginAssetContentUnsafe(file, content) { violations = append(violations, field+".content is unsafe") } if file.Mode != 0 && (file.Mode < 0o400 || file.Mode > 0o700 || file.Mode&0o022 != 0) { @@ -292,6 +301,31 @@ func validatePluginAssetFiles(prefix string, files []domain.PluginAssetFile) []s return violations } +func pluginAssetContentBytes(file domain.PluginAssetFile) ([]byte, error) { + switch strings.TrimSpace(file.Encoding) { + case "": + return []byte(file.Content), nil + case "base64": + content, err := base64.StdEncoding.DecodeString(strings.TrimSpace(file.Content)) + if err != nil { + return nil, fmt.Errorf(".content is not valid base64") + } + return content, nil + default: + return nil, fmt.Errorf(".encoding is unsupported") + } +} + +func pluginAssetContentUnsafe(file domain.PluginAssetFile, content []byte) bool { + if strings.TrimSpace(file.Encoding) == "base64" { + if bytes.ContainsRune(content, '\x00') || !utf8.Valid(content) { + return false + } + return containsUnsafeRuntimeSecret(string(content)) + } + return bytes.ContainsRune(content, '\x00') || containsUnsafeRuntimeSecret(string(content)) +} + func validateRegistrationAssetCoverage(declared []domain.PluginAssetFile, payload []domain.PluginAssetFile) []string { if len(declared) == 0 { return nil diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index b2e3602..c9b291d 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -1,6 +1,7 @@ package validator import ( + "encoding/base64" "strings" "testing" @@ -79,10 +80,12 @@ func TestValidateGamePluginManifestRegistrationValidatesAssetFileCoverage(t *tes registration.Manifest.AssetFiles = []domain.PluginAssetFile{ {Path: "actions/install.json", Mode: 0o600}, {Path: "bin/install-server", Mode: 0o700}, + {Path: "assets/map.bin", Mode: 0o600}, } registration.AssetFiles = []domain.PluginAssetFile{ {Path: "actions/install.json", Content: "{}", Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, + {Path: "assets/map.bin", Content: base64.StdEncoding.EncodeToString([]byte{0xff, 0x00, 0x7f}), Encoding: "base64", Mode: 0o600}, } if err := ValidateGamePluginManifestRegistration(registration); err != nil { t.Fatalf("expected declared asset files with matching content to validate, got %v", err) @@ -105,6 +108,12 @@ func TestValidateGamePluginManifestRegistrationValidatesAssetFileCoverage(t *tes if err := ValidateGamePluginManifestRegistration(inlineContent); err == nil || !strings.Contains(err.Error(), "content must be supplied only") { t.Fatalf("expected manifest inline content rejection, got %v", err) } + + unsafeEncodedText := domain.CopyGamePluginManifestRegistration(registration) + unsafeEncodedText.AssetFiles[2].Content = base64.StdEncoding.EncodeToString([]byte("password=secret")) + if err := ValidateGamePluginManifestRegistration(unsafeEncodedText); err == nil || !strings.Contains(err.Error(), "content is unsafe") { + t.Fatalf("expected unsafe base64 text rejection, got %v", err) + } } func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) { diff --git a/scripts/README.md b/scripts/README.md index f5d076a..292e293 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,6 +9,6 @@ Use these root-level commands for day-to-day work: - `scripts/dev-reset.sh`: stop the stack and delete only the safe local debug data root. - `scripts/browser-acceptance.sh`: run the full self-starting browser acceptance suite. - `scripts/check-structure.sh`: verify required repository structure. -- `scripts/check-all.sh`: run structure, backend, frontend, and plugin checks. +- `scripts/check-all.sh`: run structure, backend, frontend, plugin checks, and the independent Run checkout tests when `RUN_SOURCE_DIR` or `run/` is available. Implementation details for the managed local stack live under `scripts/local-debug/`. Prefer the root commands above instead of calling those internals directly. diff --git a/scripts/check-all.sh b/scripts/check-all.sh index 78c1f38..1db2b41 100755 --- a/scripts/check-all.sh +++ b/scripts/check-all.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$ROOT_DIR/run}}" ensure_node_deps() { local dir="$1" @@ -14,6 +15,12 @@ ensure_node_deps() { (cd "$ROOT_DIR/platform" && go test ./...) +if [[ -d "$RUN_SOURCE_DIR/.git" ]]; then + (cd "$RUN_SOURCE_DIR" && go test ./...) +else + printf 'independent run checkout not found at %s; skipping run checks\n' "$RUN_SOURCE_DIR" +fi + ensure_node_deps "$ROOT_DIR/platform_web" (cd "$ROOT_DIR/platform_web" && npm run typecheck && npm run test && npm run build) diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 9afa703..6537e37 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -378,6 +378,23 @@ create_server_workflow() { exit 1 } +create_server_instance() { + local label="$1" + local server_id="$2" + local request_file="$3" + local response_file="$4" + if json_post "$API_URL/server-instances" "$request_file" "$response_file" "${AUTH_HEADER[@]}"; then + return 0 + fi + if [[ -s "$response_file" ]] && response_code_is_duplicate "$response_file"; then + json_get "$API_URL/server-instances/$server_id" "$response_file" "${AUTH_HEADER[@]}" + return 0 + fi + printf '%s server creation failed; platform response:\n' "$label" >&2 + sed -n '1,160p' "$response_file" >&2 + exit 1 +} + require_file_contains() { local file="$1" local pattern="$2" @@ -970,9 +987,14 @@ function readAssetFiles(manifest) { return (manifest.assetFiles ?? []).map((file) => ({ path: file.path, mode: file.mode, - content: fs.readFileSync(path.join(manifestDir, file.path), "utf8") + ...readAssetFileContent(path.join(manifestDir, file.path), file.path) })); } +function readAssetFileContent(assetPath, logicalPath) { + const body = fs.readFileSync(assetPath); + if (/\.(?:json|cmd|sh|sql|txt|ya?ml)$/i.test(logicalPath)) return { content: body.toString("utf8") }; + return { content: body.toString("base64"), encoding: "base64" }; +} const manifest = { id: source.id, name: source.name, @@ -1046,9 +1068,14 @@ function readAssetFiles(manifest) { return (manifest.assetFiles ?? []).map((file) => ({ path: file.path, mode: file.mode, - content: fs.readFileSync(path.join(manifestDir, file.path), "utf8") + ...readAssetFileContent(path.join(manifestDir, file.path), file.path) })); } +function readAssetFileContent(assetPath, logicalPath) { + const body = fs.readFileSync(assetPath); + if (/\.(?:json|cmd|sh|sql|txt|ya?ml)$/i.test(logicalPath)) return { content: body.toString("utf8") }; + return { content: body.toString("base64"), encoding: "base64" }; +} const localRunCapabilities = [ "process.install", "process.start", @@ -1149,8 +1176,8 @@ cat >"$WORK_DIR/create-log-session-server.request.json" </dev/null; then + local previous_fingerprint="" + if [[ -n "$fingerprint" && -f "$fingerprint_file" ]]; then + previous_fingerprint="$(cat "$fingerprint_file")" + fi + if [[ -n "$fingerprint" && "$previous_fingerprint" != "$fingerprint" ]]; then + printf '%s source or environment changed; restarting pid %s\n' "$name" "$existing_pid" + stop_pid_tree "$name" "$existing_pid" + rm -f "$pid_file" + else printf '%s already running with pid %s\n' "$name" "$existing_pid" return 0 + fi + else + rm -f "$pid_file" "$fingerprint_file" fi - rm -f "$pid_file" fi printf 'starting %s\n' "$name" @@ -167,6 +213,11 @@ start_service() { exec nohup "$@" >"$LOCAL_DEBUG_LOG_DIR/$name.log" 2>&1 ) >/dev/null 2>&1 & printf '%s' "$!" >"$pid_file" + if [[ -n "$fingerprint" ]]; then + printf '%s' "$fingerprint" >"$fingerprint_file" + else + rm -f "$fingerprint_file" + fi printf '%s pid %s log %s\n' "$name" "$(cat "$pid_file")" "$LOCAL_DEBUG_LOG_DIR/$name.log" } @@ -202,10 +253,26 @@ printf 'platform log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform.log" printf 'run log: %s\n' "$LOCAL_DEBUG_LOG_DIR/run.log" printf 'platform_web log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform_web.log" +platform_fingerprint="$(combined_fingerprint \ + "$(tracked_tree_fingerprint "$ROOT_DIR" platform scripts/dev-start.sh scripts/local-debug)" \ + "PLATFORM_ADDR=$PLATFORM_ADDR" \ + "PLATFORM_STORAGE_BACKEND=$PLATFORM_STORAGE_BACKEND" \ + "PLATFORM_METADATA_PATH=$PLATFORM_METADATA_PATH" \ + "PLATFORM_LOG_BODY_BACKEND=$PLATFORM_LOG_BODY_BACKEND" \ + "PLATFORM_RUN_RELEASE_URL=$PLATFORM_RUN_RELEASE_URL" \ + "PLATFORM_BUILDER_IMAGE=$PLATFORM_BUILDER_IMAGE" \ + "PLATFORM_BUILDER_SOURCE_DIR=$PLATFORM_BUILDER_SOURCE_DIR" \ + "PLATFORM_BUILDER_SOURCE_REVISION=$PLATFORM_BUILDER_SOURCE_REVISION")" +web_fingerprint="$(combined_fingerprint \ + "$(tracked_tree_fingerprint "$ROOT_DIR" platform_web package.json package-lock.json scripts/dev-start.sh scripts/local-debug)" \ + "PLATFORM_API_PROXY=$PLATFORM_API_PROXY" \ + "VITE_PLATFORM_API_BASE_URL=$VITE_PLATFORM_API_BASE_URL" \ + "VITE_ENABLE_LOCAL_AUTH_FALLBACK=$VITE_ENABLE_LOCAL_AUTH_FALLBACK")" + ensure_port_available platform "$LOCAL_DEBUG_PLATFORM_PORT" ensure_port_available platform_web "$LOCAL_DEBUG_WEB_PORT" -start_service platform "$ROOT_DIR/platform" env \ +start_service platform "$ROOT_DIR/platform" "$platform_fingerprint" env \ GOCACHE="$GOCACHE" \ PLATFORM_ADDR="$PLATFORM_ADDR" \ PLATFORM_STORAGE_BACKEND="$PLATFORM_STORAGE_BACKEND" \ @@ -230,7 +297,7 @@ start_service platform "$ROOT_DIR/platform" env \ wait_for_url platform "$(local_debug_platform_url)/healthz" -start_service platform_web "$ROOT_DIR" env \ +start_service platform_web "$ROOT_DIR" "$web_fingerprint" env \ PLATFORM_API_PROXY="$PLATFORM_API_PROXY" \ VITE_PLATFORM_API_BASE_URL="$VITE_PLATFORM_API_BASE_URL" \ VITE_ENABLE_LOCAL_AUTH_FALLBACK="$VITE_ENABLE_LOCAL_AUTH_FALLBACK" \ @@ -240,7 +307,22 @@ wait_for_url platform_web "$(local_debug_web_url)" stop_orphaned_run_bootstrap local_debug_build_bootstrap_run -start_service run "$(dirname "$RUN_BOOTSTRAP_BIN")" env \ +run_fingerprint="$(combined_fingerprint \ + "$(tracked_tree_fingerprint "$RUN_SOURCE_DIR" .)" \ + "$(file_fingerprint "$RUN_BOOTSTRAP_BIN")" \ + "RUN_MODE=$RUN_MODE" \ + "RUN_PLATFORM_URL=$RUN_PLATFORM_URL" \ + "RUN_ENDPOINT_ID=$RUN_ENDPOINT_ID" \ + "RUN_DISPLAY_NAME=$RUN_DISPLAY_NAME" \ + "RUN_VERSION=$RUN_VERSION" \ + "RUN_WORKSPACE_ROOT=$RUN_WORKSPACE_ROOT" \ + "RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT" \ + "RUN_SPOOL_ROOT=$RUN_SPOOL_ROOT" \ + "RUN_MAX_JOBS=$RUN_MAX_JOBS" \ + "RUN_HEARTBEAT_INTERVAL_MS=$RUN_HEARTBEAT_INTERVAL_MS" \ + "RUN_POLL_INTERVAL_MS=$RUN_POLL_INTERVAL_MS" \ + "RUN_RETRY_BACKOFF_MS=$RUN_RETRY_BACKOFF_MS")" +start_service run "$(dirname "$RUN_BOOTSTRAP_BIN")" "$run_fingerprint" env \ GOCACHE="$GOCACHE" \ RUN_MODE="$RUN_MODE" \ RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \