Files
browser/platform/service/distribution_build_execution.go
T

371 lines
13 KiB
Go

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)
}