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() }() _ = svc.executeDistributionBuild(job) }() } // 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 } payload, buildErr := svc.configuredDistributionBuilder().Build(input) if buildErr != nil { return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr)) } 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 } profileKey, workspaceSeed, err := svc.runDistributionWorkspaceSeed(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: 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: workspaceSeed, }, 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 } func (svc *CoreService) runDistributionWorkspaceSeed(distribution domain.RunDistribution) (string, string, error) { instance, err := svc.store.ServerInstances().Get(distribution.ServerInstanceID) if err != nil { return "", "", err } plugin, err := svc.store.GamePlugins().Get(distribution.PluginID) if err != nil { return "", "", err } seed, err := encodePluginWorkspaceSeed(plugin.LifecycleAssets) if err != nil { return "", "", err } profileKey := instance.Deployment.ProfileKey if binding, bindingErr := svc.runtimeBindingForServer(distribution.ServerInstanceID); bindingErr == nil { profileKey = binding.ProfileKey } else if !errors.Is(bindingErr, repo.ErrNotFound) { return "", "", bindingErr } return profileKey, seed, nil } func encodePluginWorkspaceSeed(files []domain.PluginAssetFile) (string, error) { if len(files) == 0 { return "", nil } type seedFile struct { Path string `json:"path"` Content string `json:"content"` 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} } 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.artifactPayloads[artifact.ID] = domain.CopyBytes(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: "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) 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) }