package service import ( "encoding/base64" "encoding/json" "errors" "strings" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const platformDistributionBuilderEndpointID = "platform-distribution-builder" // unconfiguredDistributionBuilder stands in when no platform builder has been // installed. It reports a platform-builder reason so an operator is not sent // looking at run endpoint capabilities. type unconfiguredDistributionBuilder struct{} func (unconfiguredDistributionBuilder) Readiness() (bool, string) { return false, "platform builder is not configured" } func (unconfiguredDistributionBuilder) Build(domain.DistributionBuildInput) ([]byte, error) { return nil, validationError("platform builder is not configured") } func (svc *CoreService) configuredDistributionBuilder() DistributionBuilder { svc.distributionBuildMu.Lock() defer svc.distributionBuildMu.Unlock() if svc.distributionBuilder == nil { return unconfiguredDistributionBuilder{} } return svc.distributionBuilder } // distributionBuilderReadiness reports platform builder readiness. The reason // always names the platform builder, never a run endpoint capability. func (svc *CoreService) distributionBuilderReadiness() (bool, string) { builder := svc.configuredDistributionBuilder() ready, reason := builder.Readiness() if ready { return true, "" } reason = strings.TrimSpace(reason) if reason == "" { reason = "platform builder is unavailable" } else if !strings.Contains(strings.ToLower(reason), "platform builder") { reason = "platform builder is unavailable: " + reason } return false, reason } func (svc *CoreService) enqueueDistributionBuild(job domain.Job) { svc.distributionBuildMu.Lock() if _, exists := svc.distributionBuilds[job.ID]; exists { svc.distributionBuildMu.Unlock() return } svc.distributionBuilds[job.ID] = struct{}{} svc.distributionBuildMu.Unlock() go func() { defer func() { svc.distributionBuildMu.Lock() delete(svc.distributionBuilds, job.ID) svc.distributionBuildMu.Unlock() }() defer func() { if recover() != nil { _ = svc.failDistributionBuildJob(job, "platform builder failed unexpectedly") } }() if err := svc.executeDistributionBuild(job); err != nil { // executeDistributionBuild may fail after persisting the running state. // A second terminalization attempt is idempotent when the specific // failure path already marked the job failed. _ = svc.failDistributionBuildJob(job, "platform builder failed before completion") } }() } // executeDistributionBuild claims the build job internally and completes it with // the artifact produced by the platform builder. Build work is never dispatched // to a machine-side run endpoint, so the component auth key stays on the // platform. func (svc *CoreService) executeDistributionBuild(job domain.Job) error { if job.Capability != domain.JobCapabilityDistributionBuild { return validationError("job is not a distribution build") } input, err := svc.platformDistributionBuildInput(job) if err != nil { return svc.failDistributionBuildJob(job, "platform builder could not assemble build input") } if err := svc.markDistributionBuildRunning(&job); err != nil { return err } if isTerminalJobState(job.State) { return nil } builder := svc.configuredDistributionBuilder() var payload []byte var buildErr error if progressBuilder, ok := builder.(distributionBuilderWithProgress); ok { payload, buildErr = progressBuilder.BuildWithProgress(input, func(progress DistributionBuildProgress) { _ = svc.updateDistributionBuildProgress(job, progress) }) } else { payload, buildErr = builder.Build(input) } if buildErr != nil { return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey)) } if _, err := svc.platformDistributionBuildInput(job); err != nil { return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current") } if err := svc.storeDistributionBuildArtifact(input.ArtifactID, job.ID, payload); err != nil { return svc.failDistributionBuildJob(job, "platform builder could not record the distribution artifact") } return svc.succeedDistributionBuildJob(job, input.ArtifactID) } // platformDistributionBuildInput resolves the build input, including the // component auth key, inside the platform. Unlike GetDistributionBuildInput it // never crosses the job channel. func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.DistributionBuildInput, error) { runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { return domain.DistributionBuildInput{}, err } for _, distribution := range runDistributions { if distribution.BuildJobID != job.ID { continue } key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "") if err != nil { return domain.DistributionBuildInput{}, err } if key.Generation != distribution.KeyGeneration { return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current") } plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) if err != nil { return domain.DistributionBuildInput{}, err } packageInput, err := svc.runDistributionPackageContext(distribution) if err != nil { return domain.DistributionBuildInput{}, err } return domain.DistributionBuildInput{ JobID: job.ID, ComponentKind: domain.DistributionComponentRun, ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, ProfileKey: packageInput.profileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, PlatformURL: runReleasePlatformURL(), PackageFormat: distribution.PackageFormat, ArtifactID: distribution.ArtifactID, OutputFilename: executableFilename("run", distribution.TargetOS), SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plainKey, WorkspaceSeed: packageInput.workspaceSeed, AutonomousLifecycle: packageInput.autonomousLifecycle, }, nil } clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { return domain.DistributionBuildInput{}, err } for _, distribution := range clientDistributions { if distribution.BuildJobID != job.ID { continue } key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey) if err != nil { return domain.DistributionBuildInput{}, err } if key.Generation != distribution.KeyGeneration { return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current") } plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) if err != nil { return domain.DistributionBuildInput{}, err } return domain.DistributionBuildInput{ JobID: job.ID, ComponentKind: domain.DistributionComponentClientManager, ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: job.RunEndpointID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, PlatformURL: runReleasePlatformURL(), PackageFormat: packageFormatForTarget(distribution.TargetOS), RepositoryURL: distribution.RepositoryURL, SourceRevision: distribution.SourceRevision, ArtifactID: distribution.ArtifactID, OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS), SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plainKey, }, nil } return domain.DistributionBuildInput{}, repo.ErrNotFound } type runDistributionPackageContext struct { profileKey string workspaceSeed string autonomousLifecycle *domain.RunAutonomousLifecyclePlan } func (svc *CoreService) runDistributionPackageContext(distribution domain.RunDistribution) (runDistributionPackageContext, error) { instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) if err != nil { return runDistributionPackageContext{}, err } plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) if err != nil { return runDistributionPackageContext{}, err } profileKey := instance.Deployment.ProfileKey bindings := map[string]string(nil) if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil { profileKey = binding.ProfileKey bindings = domain.CopyStringMap(binding.Bindings) } else if !errors.Is(bindingErr, repo.ErrNotFound) { return runDistributionPackageContext{}, bindingErr } profileKey = defaultRunDistributionProfileKey(plugin, profileKey) plan, err := runAutonomousLifecyclePlan(distribution, instance, plugin, profileKey, bindings) if err != nil { return runDistributionPackageContext{}, err } seed, err := encodeRunWorkspaceSeed(plugin.LifecycleAssets, plan) if err != nil { return runDistributionPackageContext{}, err } return runDistributionPackageContext{profileKey: profileKey, workspaceSeed: seed, autonomousLifecycle: plan}, nil } func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance domain.ServerInstance, plugin domain.GamePlugin, profileKey string, bindings map[string]string) (*domain.RunAutonomousLifecyclePlan, error) { privateBindings := autonomousDataTargetBindings(bindings, instance, plugin.RuntimeProfiles.DataTargets) plan := &domain.RunAutonomousLifecyclePlan{ SchemaVersion: "1", ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: distribution.RunEndpointID, ProfileKey: profileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, DeploymentRevision: instance.Deployment.Revision, RuntimeBindings: privateBindings, Deployment: autonomousDeploymentFromDefinition(instance.Deployment, profileKey, privateBindings), } profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey) for _, action := range []domain.ServerLifecycleAction{domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus} { entry := autonomousLifecycleAction(plugin, profile, hasProfile, action) if entry.TargetKey == "" { continue } plan.Actions = append(plan.Actions, entry) } bootstrap := autonomousLifecycleAction(plugin, profile, hasProfile, autonomousBootstrapLifecycleAction(plugin, profile, hasProfile)) if bootstrap.TargetKey != "" { plan.Bootstrap = &bootstrap } for _, probe := range plugin.RuntimeProfiles.DependencyProbes { if runtimePlatformsContain(probe.Platforms, distribution.TargetOS) { plan.DependencyProbes = append(plan.DependencyProbes, autonomousDependencyProbe(probe)) } } for _, installPlan := range plugin.RuntimeProfiles.InstallPlans { if runtimePlatformsContain(installPlan.Platforms, distribution.TargetOS) { plan.InstallPlans = append(plan.InstallPlans, autonomousInstallPlan(installPlan)) } } for _, source := range plugin.RuntimeProfiles.LogSources { if autonomousRunLogSourceKind(source.Kind) { plan.LogSources = append(plan.LogSources, autonomousLogSource(source)) } } for _, target := range plugin.RuntimeProfiles.DataTargets { if runtimePlatformsContain(target.Platforms, distribution.TargetOS) { plan.DataTargets = append(plan.DataTargets, autonomousDataTarget(target)) } } if hasProfile && len(profile.DLLExtensionRefs) > 0 { endpoint := domain.RunEndpoint{ID: distribution.RunEndpointID, Platform: distribution.TargetOS, Architecture: distribution.TargetArch} extensions, err := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint) if err != nil { return nil, err } for _, extension := range extensions { plan.DLLExtensions = append(plan.DLLExtensions, autonomousDLLExtension(extension)) } } return domain.CopyRunAutonomousLifecyclePlanPtr(plan), nil } func autonomousDataTargetBindings(bindings map[string]string, instance domain.ServerInstance, targets []domain.RuntimeDataTarget) map[string]string { result := domain.CopyStringMap(bindings) if result == nil { result = map[string]string{} } for _, target := range targets { if result[target.SourceRootKey] == "" && target.SourceRootKey == "server-root" && instance.Deployment.ServerRoot != "" { result[target.SourceRootKey] = instance.Deployment.ServerRoot } } return result } func autonomousLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool, action domain.ServerLifecycleAction) domain.RunAutonomousLifecycleAction { targetKey := "" if hasProfile { targetKey = runtimeProfileActionRef(profile.ActionRefs, action) } if targetKey == "" { targetKey = lifecycleActionRef(plugin, action) } if targetKey == "" { return domain.RunAutonomousLifecycleAction{} } return domain.RunAutonomousLifecycleAction{Action: action, Operation: lifecycleExecutionOperation(action), Capability: domain.LifecycleCapabilityForAction(action), TargetKey: targetKey} } func autonomousBootstrapLifecycleAction(plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile, hasProfile bool) domain.ServerLifecycleAction { if autonomousLifecycleAction(plugin, profile, hasProfile, domain.ServerLifecycleActionStart).TargetKey != "" { return domain.ServerLifecycleActionStart } return domain.ServerLifecycleActionCreate } func autonomousDependencyProbe(probe domain.RuntimeDependencyProbe) domain.RunAutonomousDependencyProbe { return domain.RunAutonomousDependencyProbe{Key: probe.Key, Kind: probe.Kind, TargetKey: probe.TargetKey, Required: probe.Required, MinimumVersion: probe.MinimumVersion, Platforms: domain.CopyStringSlice(probe.Platforms)} } func autonomousInstallPlan(plan domain.RuntimeInstallPlan) domain.RunAutonomousInstallPlan { steps := make([]domain.RunAutonomousInstallStep, len(plan.Steps)) for i, step := range plan.Steps { steps[i] = domain.RunAutonomousInstallStep{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadRef: step.DownloadRef, Checksum: step.Checksum} } return domain.RunAutonomousInstallPlan{Key: plan.Key, Title: plan.Title, Platforms: domain.CopyStringSlice(plan.Platforms), Steps: steps} } func autonomousLogSource(source domain.RuntimeLogSource) domain.RunAutonomousLogSource { return domain.RunAutonomousLogSource{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays} } func autonomousRunLogSourceKind(kind string) bool { return kind == "process.stdout" || kind == "process.stderr" } func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension { return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort} } func autonomousDataTarget(target domain.RuntimeDataTarget) domain.RunAutonomousDataTarget { return domain.RunAutonomousDataTarget{Key: target.Key, Kind: target.Kind, TransportKey: target.TransportKey, SourceRootKey: target.SourceRootKey, SourcePath: target.SourcePath, WorkspaceKey: target.WorkspaceKey, RefreshPolicy: target.RefreshPolicy, MaxBytes: target.MaxBytes, Platforms: domain.CopyStringSlice(target.Platforms)} } func autonomousDeploymentFromDefinition(definition domain.ServerDeploymentDefinition, profileKey string, bindings map[string]string) *domain.RunAutonomousDeployment { if definition.Mode == "" { return nil } copy := domain.CopyServerDeploymentDefinition(definition) if copy.ProfileKey == "" { copy.ProfileKey = profileKey } if len(copy.RuntimeBindings) == 0 { copy.RuntimeBindings = domain.CopyStringMap(bindings) } return &domain.RunAutonomousDeployment{SchemaVersion: "1", Mode: copy.Mode, ProfileKey: copy.ProfileKey, RuntimeBindings: copy.RuntimeBindings, CreateInputs: copy.CreateInputs, ServerRoot: copy.ServerRoot, WorkingDirectory: copy.WorkingDirectory, InstallCommand: copy.InstallCommand, StartCommand: copy.StartCommand, StopCommand: copy.StopCommand, StatusCommand: copy.StatusCommand, Shell: copy.Shell, Revision: copy.Revision} } func encodeRunWorkspaceSeed(files []domain.PluginAssetFile, plan *domain.RunAutonomousLifecyclePlan) (string, error) { seedFiles := append([]domain.PluginAssetFile(nil), files...) if plan != nil { payload, err := json.Marshal(plan) if err != nil { return "", err } seedFiles = append(seedFiles, domain.PluginAssetFile{Path: ".platform/autonomous-lifecycle-plan.json", Content: string(payload), Mode: 0o600}) } return encodePluginWorkspaceSeed(seedFiles) } func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) { if len(files) == 0 { return "", nil } type seedFile struct { 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, Encoding: file.Encoding, Mode: file.Mode} } body, err := json.Marshal(seed) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(body), nil } // storeDistributionBuildArtifact records the built package under job ownership // so the existing ArtifactOwnerKindJob scope assertions keep guarding it. func (svc *CoreService) storeDistributionBuildArtifact(artifactID string, jobID string, payload []byte) error { if strings.TrimSpace(artifactID) == "" { return validationError("distribution build artifact id is required") } if len(payload) == 0 { return validationError("distribution build produced no package bytes") } stamp := svc.now() svc.artifactMu.Lock() defer svc.artifactMu.Unlock() artifact, err := svc.store.Artifacts().Get(artifactID) if err != nil && !errors.Is(err, repo.ErrNotFound) { return err } create := errors.Is(err, repo.ErrNotFound) if create { artifact = domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: jobID, CreatedAt: stamp} } if artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != jobID { return validationError("distribution build artifact is outside the job scope") } artifact.SizeBytes = int64(len(payload)) artifact.Checksum = validator.BytesChecksum(payload) artifact.State = domain.ArtifactStateAvailable artifact.UpdatedAt = stamp if err := validator.ValidateArtifact(artifact); err != nil { return err } if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil { return err } if create { if err := svc.store.Artifacts().Create(artifact); err != nil { return err } } else if err := svc.store.Artifacts().Update(artifact); err != nil { return err } svc.cacheArtifactPayload(artifact.ID, payload) return nil } func (svc *CoreService) markDistributionBuildRunning(job *domain.Job) error { stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() current, err := svc.store.Jobs().Get(job.ID) if err != nil { return err } if isTerminalJobState(current.State) { *job = current return nil } current.State = domain.JobStateRunning current.Attempt = maxInt(current.Attempt, 1) current.Progress = domain.JobProgress{Percent: 5, Phase: current.Progress.Phase, Message: "env_check: platform builder started"} current.UpdatedAt = stamp if err := svc.updateScheduledJob(current); err != nil { return err } if err := svc.projectDistributionBuildProgress(current, stamp); err != nil { return err } *job = current return nil } func (svc *CoreService) updateDistributionBuildProgress(job domain.Job, progress DistributionBuildProgress) error { stamp := svc.now() svc.jobMu.Lock() defer svc.jobMu.Unlock() current, err := svc.store.Jobs().Get(job.ID) if err != nil { return err } if isTerminalJobState(current.State) { return nil } if progress.Percent < current.Progress.Percent { progress.Percent = current.Progress.Percent } current.Progress = domain.JobProgress{Percent: progress.Percent, Phase: current.Progress.Phase, Message: strings.TrimSpace(progress.Message)} current.UpdatedAt = stamp if err := svc.updateScheduledJob(current); err != nil { return err } return svc.projectDistributionBuildProgress(current, stamp) } func (svc *CoreService) succeedDistributionBuildJob(job domain.Job, artifactID string) error { stamp := svc.now() svc.jobMu.Lock() current, err := svc.store.Jobs().Get(job.ID) if err != nil { svc.jobMu.Unlock() return err } if isTerminalJobState(current.State) { if current.State == domain.JobStateSucceeded && current.ResultRef == "artifact://"+artifactID { svc.jobMu.Unlock() return nil } svc.jobMu.Unlock() _ = svc.expireDistributionArtifact(artifactID) return nil } current.State = domain.JobStateSucceeded current.ResultRef = "artifact://" + artifactID current.Progress = domain.JobProgress{Percent: 100, Phase: current.Progress.Phase, Message: "platform builder completed"} current.LeaseTokenHash = "" current.TerminalAt = stamp current.TerminalFingerprint = "platform-builder:succeeded:" + artifactID current.UpdatedAt = stamp if err := svc.updateScheduledJob(current); err != nil { svc.jobMu.Unlock() return err } svc.jobMu.Unlock() return svc.projectDistributionBuildResult(current, stamp) } func (svc *CoreService) failDistributionBuildJob(job domain.Job, reason string) error { stamp := svc.now() if strings.TrimSpace(reason) == "" { reason = "platform builder failed" } svc.jobMu.Lock() current, err := svc.store.Jobs().Get(job.ID) if err != nil { svc.jobMu.Unlock() return err } if isTerminalJobState(current.State) { svc.jobMu.Unlock() return nil } current.State = domain.JobStateFailed current.Progress = domain.JobProgress{Percent: current.Progress.Percent, Phase: current.Progress.Phase, Message: reason} current.LeaseTokenHash = "" current.TerminalAt = stamp current.TerminalFingerprint = "platform-builder:failed:" + reason current.UpdatedAt = stamp if err := svc.updateScheduledJob(current); err != nil { svc.jobMu.Unlock() return err } svc.jobMu.Unlock() if err := svc.projectDistributionBuildResult(current, stamp); err != nil && !errors.Is(err, repo.ErrNotFound) { return err } return validationError(reason) }