feat: move distribution builds to platform Docker builder
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
return domain.DistributionBuildInput{
|
||||
JobID: job.ID,
|
||||
ComponentKind: domain.DistributionComponentRun,
|
||||
ServerInstanceID: distribution.ServerInstanceID,
|
||||
PluginID: distribution.PluginID,
|
||||
RunEndpointID: distribution.RunEndpointID,
|
||||
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,
|
||||
}, 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user