607 lines
26 KiB
Go
607 lines
26 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
const runUpdateChunkSize = 1024 * 1024
|
|
|
|
type dependencyResolution struct {
|
|
instance domain.ServerInstance
|
|
plugin domain.GamePlugin
|
|
binding domain.RuntimeBinding
|
|
endpoint domain.RunEndpoint
|
|
}
|
|
|
|
func (svc *CoreService) GetDependencyCatalogForSession(sessionID, serverInstanceID string) (domain.DependencyCatalog, error) {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
|
if err != nil {
|
|
return domain.DependencyCatalog{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return domain.DependencyCatalog{}, err
|
|
}
|
|
if !pluginDeclares(plugin, "server.dependencies.manage") {
|
|
return domain.DependencyCatalog{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage")
|
|
}
|
|
resolution, err := svc.resolveDependencyContext(serverInstanceID)
|
|
if err != nil {
|
|
return domain.DependencyCatalog{}, err
|
|
}
|
|
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return domain.DependencyCatalog{}, err
|
|
}
|
|
statusByProbe := map[string]domain.DependencyStatus{}
|
|
for _, status := range statuses {
|
|
statusByProbe[status.ProbeKey] = status
|
|
}
|
|
|
|
plans := make([]domain.DependencyPlanView, 0, len(resolution.plugin.RuntimeProfiles.InstallPlans))
|
|
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
|
|
if !runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) {
|
|
continue
|
|
}
|
|
steps := make([]domain.DependencyPlanStepView, len(plan.Steps))
|
|
for i, step := range plan.Steps {
|
|
host := ""
|
|
if parsed, parseErr := url.Parse(step.DownloadRef); parseErr == nil {
|
|
host = parsed.Hostname()
|
|
}
|
|
steps[i] = domain.DependencyPlanStepView{Type: step.Type, TargetKey: step.TargetKey, PackageManager: step.PackageManager, PackageName: step.PackageName, Version: step.Version, DownloadHost: host}
|
|
}
|
|
var planProbe domain.RuntimeDependencyProbe
|
|
for _, candidate := range resolution.plugin.RuntimeProfiles.DependencyProbes {
|
|
if runtimePlatformsContain(candidate.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, candidate) {
|
|
planProbe = candidate
|
|
break
|
|
}
|
|
}
|
|
plans = append(plans, domain.DependencyPlanView{Key: plan.Key, Title: plan.Title, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Digest: dependencyPlanDigest(resolution, planProbe, plan), Steps: steps})
|
|
}
|
|
sort.Slice(plans, func(i, j int) bool { return plans[i].Key < plans[j].Key })
|
|
|
|
probes := make([]domain.DependencyProbeView, 0, len(resolution.plugin.RuntimeProfiles.DependencyProbes))
|
|
for _, probe := range resolution.plugin.RuntimeProfiles.DependencyProbes {
|
|
if !runtimePlatformsContain(probe.Platforms, resolution.endpoint.Platform) {
|
|
continue
|
|
}
|
|
status := statusByProbe[probe.Key]
|
|
planKey := ""
|
|
for _, plan := range resolution.plugin.RuntimeProfiles.InstallPlans {
|
|
if runtimePlatformsContain(plan.Platforms, resolution.endpoint.Platform) && planTargetsProbe(plan, probe) {
|
|
planKey = plan.Key
|
|
break
|
|
}
|
|
}
|
|
state := status.State
|
|
if state == "" {
|
|
state = domain.DependencyStateUnknown
|
|
}
|
|
probes = append(probes, domain.DependencyProbeView{Key: probe.Key, Kind: probe.Kind, Required: probe.Required, MinimumVersion: probe.MinimumVersion, State: state, Evidence: status.Evidence, InstallPlanKey: planKey})
|
|
}
|
|
sort.Slice(probes, func(i, j int) bool { return probes[i].Key < probes[j].Key })
|
|
|
|
return domain.CopyDependencyCatalog(domain.DependencyCatalog{ServerInstanceID: resolution.instance.ID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, Probes: probes, Plans: plans, UpdatedAt: svc.now()}), nil
|
|
}
|
|
|
|
func (svc *CoreService) ListRunUpdateJobsForSession(sessionID, serverInstanceID string) ([]domain.RunUpdateJob, error) {
|
|
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
|
|
for i := range items {
|
|
items[i] = domain.CopyRunUpdateJob(items[i])
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (svc *CoreService) GetDependencyExecutionInput(request domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) {
|
|
if err := validator.ValidateDependencyExecutionInputRequest(request); err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
if job.Capability != domain.JobCapabilityDependenciesCheck && job.Capability != domain.JobCapabilityDependenciesInstall {
|
|
return domain.DependencyExecutionInput{}, validationError("job is not a dependency operation")
|
|
}
|
|
resolution, err := svc.resolveDependencyContext(job.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
if resolution.endpoint.ID != job.RunEndpointID {
|
|
return domain.DependencyExecutionInput{}, validationError("dependency endpoint no longer matches")
|
|
}
|
|
probeKey := strings.TrimPrefix(job.TargetKey, "dependencies/")
|
|
if job.Capability == domain.JobCapabilityDependenciesInstall {
|
|
probeKey = ""
|
|
}
|
|
var probe domain.RuntimeDependencyProbe
|
|
if probeKey != "" {
|
|
probe, err = declaredDependencyProbe(resolution.plugin, probeKey, resolution.endpoint.Platform)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
}
|
|
var plan domain.RuntimeInstallPlan
|
|
if job.Capability == domain.JobCapabilityDependenciesInstall {
|
|
planKey := strings.TrimPrefix(job.TargetKey, "dependencies/install/")
|
|
plan, err = declaredInstallPlan(resolution.plugin, planKey, resolution.endpoint.Platform)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
statuses, listErr := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if listErr != nil {
|
|
return domain.DependencyExecutionInput{}, listErr
|
|
}
|
|
for _, status := range statuses {
|
|
if status.JobID == job.ID {
|
|
probe, err = declaredDependencyProbe(resolution.plugin, status.ProbeKey, resolution.endpoint.Platform)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
digest := dependencyPlanDigest(resolution, probe, plan)
|
|
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
if status.PlanDigest != digest {
|
|
return domain.DependencyExecutionInput{}, validationError("dependency declaration changed after dispatch")
|
|
}
|
|
bindings, err := dependencyBindings(resolution.binding, probe, plan)
|
|
if err != nil {
|
|
return domain.DependencyExecutionInput{}, err
|
|
}
|
|
return domain.CopyDependencyExecutionInput(domain.DependencyExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, PluginID: resolution.plugin.ID, PluginVersion: resolution.plugin.Version, ProfileKey: resolution.binding.ProfileKey, TargetOS: resolution.endpoint.Platform, TargetArch: resolution.endpoint.Architecture, PlanDigest: digest, Probe: probe, Plan: plan, Bindings: bindings}), nil
|
|
}
|
|
|
|
func (svc *CoreService) GetRunUpdateInput(request domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) {
|
|
if err := validator.ValidateRunUpdateInputRequest(request); err != nil {
|
|
return domain.RunUpdateInput{}, err
|
|
}
|
|
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
if err != nil {
|
|
return domain.RunUpdateInput{}, err
|
|
}
|
|
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
|
return domain.RunUpdateInput{}, validationError("job is not a Run self-update")
|
|
}
|
|
update, distribution, artifact, err := svc.resolveRunUpdate(job)
|
|
if err != nil {
|
|
return domain.RunUpdateInput{}, err
|
|
}
|
|
return domain.RunUpdateInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, ArtifactID: artifact.ID, Checksum: artifact.Checksum, SizeBytes: artifact.SizeBytes, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, PackageFormat: distribution.PackageFormat, ExecutableName: executableFilename("run", distribution.TargetOS), TargetRelease: update.TargetRelease, ChunkSizeBytes: runUpdateChunkSize}, nil
|
|
}
|
|
|
|
func (svc *CoreService) ReadRunUpdateChunk(request domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) {
|
|
if err := validator.ValidateRunUpdateChunkRequest(request); err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
|
return domain.RunUpdateChunk{}, validationError("job is not a Run self-update")
|
|
}
|
|
_, _, artifact, err := svc.resolveRunUpdate(job)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
payload, err := svc.artifactPayload(artifact.ID)
|
|
if err != nil {
|
|
return domain.RunUpdateChunk{}, err
|
|
}
|
|
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
|
return domain.RunUpdateChunk{}, validationError("update artifact content does not match metadata")
|
|
}
|
|
if request.Offset >= artifact.SizeBytes {
|
|
return domain.RunUpdateChunk{}, validationError("offset must be inside update artifact")
|
|
}
|
|
length := request.Length
|
|
remaining := artifact.SizeBytes - request.Offset
|
|
if int64(length) > remaining {
|
|
length = int(remaining)
|
|
}
|
|
end := request.Offset + int64(length)
|
|
return domain.CopyRunUpdateChunk(domain.RunUpdateChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):int(end)], Complete: end == artifact.SizeBytes}), nil
|
|
}
|
|
|
|
func (svc *CoreService) activeFencedInputJob(endpointID, sessionToken, jobID, leaseToken string, attempt int) (domain.Job, error) {
|
|
session, err := svc.validatedRunSession(endpointID, sessionToken)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
svc.jobMu.Lock()
|
|
defer svc.jobMu.Unlock()
|
|
job, err := svc.fencedJob(session, jobID, leaseToken, attempt)
|
|
if err != nil {
|
|
return domain.Job{}, err
|
|
}
|
|
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
|
return domain.Job{}, validationError("job input is not active")
|
|
}
|
|
if !job.CancelRequestedAt.IsZero() {
|
|
return domain.Job{}, validationError("job input is cancelled")
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (svc *CoreService) resolveDependencyContext(serverInstanceID string) (dependencyResolution, error) {
|
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
|
if err != nil {
|
|
return dependencyResolution{}, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return dependencyResolution{}, err
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion {
|
|
return dependencyResolution{}, validationError("installed plugin version does not match server")
|
|
}
|
|
binding, err := svc.runtimeBindingForServer(instance.ID)
|
|
if err != nil {
|
|
return dependencyResolution{}, err
|
|
}
|
|
binding, err = normalizeRuntimeBinding(plugin, binding)
|
|
if err != nil {
|
|
return dependencyResolution{}, err
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return dependencyResolution{}, err
|
|
}
|
|
if endpoint.Platform == "" || endpoint.Architecture == "" {
|
|
return dependencyResolution{}, validationError("Run endpoint target is not registered")
|
|
}
|
|
return dependencyResolution{instance: instance, plugin: plugin, binding: binding, endpoint: endpoint}, nil
|
|
}
|
|
|
|
func declaredDependencyProbe(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeDependencyProbe, error) {
|
|
for _, probe := range plugin.RuntimeProfiles.DependencyProbes {
|
|
if probe.Key == key && runtimePlatformsContain(probe.Platforms, targetOS) {
|
|
return probe, nil
|
|
}
|
|
}
|
|
return domain.RuntimeDependencyProbe{}, validationError("dependency probe is not declared for endpoint target")
|
|
}
|
|
|
|
func declaredInstallPlan(plugin domain.GamePlugin, key, targetOS string) (domain.RuntimeInstallPlan, error) {
|
|
for _, plan := range plugin.RuntimeProfiles.InstallPlans {
|
|
if plan.Key == key && runtimePlatformsContain(plan.Platforms, targetOS) {
|
|
return plan, nil
|
|
}
|
|
}
|
|
return domain.RuntimeInstallPlan{}, validationError("dependency install plan is not declared for endpoint target")
|
|
}
|
|
|
|
func runtimePlatformsContain(platforms []string, target string) bool {
|
|
if len(platforms) == 0 {
|
|
return true
|
|
}
|
|
for _, platform := range platforms {
|
|
if platform == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func planTargetsProbe(plan domain.RuntimeInstallPlan, probe domain.RuntimeDependencyProbe) bool {
|
|
for _, step := range plan.Steps {
|
|
if step.TargetKey == probe.TargetKey {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func dependencyPlanDigest(resolution dependencyResolution, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) string {
|
|
keys := make([]string, 0, len(resolution.binding.Bindings))
|
|
for key := range resolution.binding.Bindings {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
bindingEvidence := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
bindingEvidence = append(bindingEvidence, key+"="+validator.BytesChecksum([]byte(resolution.binding.Bindings[key])))
|
|
}
|
|
payload := struct {
|
|
PluginID string `json:"pluginId"`
|
|
PluginVersion string `json:"pluginVersion"`
|
|
ProfileKey string `json:"profileKey"`
|
|
TargetOS string `json:"targetOs"`
|
|
TargetArch string `json:"targetArch"`
|
|
Binding []string `json:"binding"`
|
|
Probe domain.RuntimeDependencyProbe `json:"probe"`
|
|
Plan domain.RuntimeInstallPlan `json:"plan"`
|
|
}{resolution.plugin.ID, resolution.plugin.Version, resolution.binding.ProfileKey, resolution.endpoint.Platform, resolution.endpoint.Architecture, bindingEvidence, probe, plan}
|
|
body, _ := json.Marshal(payload)
|
|
return validator.BytesChecksum(body)
|
|
}
|
|
|
|
func dependencyBindings(binding domain.RuntimeBinding, probe domain.RuntimeDependencyProbe, plan domain.RuntimeInstallPlan) (map[string]string, error) {
|
|
keys := map[string]struct{}{}
|
|
if probe.TargetKey != "" {
|
|
keys[probe.TargetKey] = struct{}{}
|
|
}
|
|
for _, step := range plan.Steps {
|
|
if step.TargetKey != "" {
|
|
keys[step.TargetKey] = struct{}{}
|
|
}
|
|
}
|
|
out := make(map[string]string, len(keys))
|
|
for key := range keys {
|
|
value := strings.TrimSpace(binding.Bindings[key])
|
|
if value == "" {
|
|
value = key
|
|
}
|
|
lower := strings.ToLower(value)
|
|
if strings.HasPrefix(lower, "secret://") || strings.Contains(lower, "password=") || strings.Contains(lower, "token=") {
|
|
return nil, validationError("dependency target binding cannot be a secret")
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (svc *CoreService) dependencyStatusForJob(jobID, serverInstanceID string) (domain.DependencyStatus, error) {
|
|
statuses, err := svc.store.DependencyStatuses().List(domain.DependencyStatusFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return domain.DependencyStatus{}, err
|
|
}
|
|
for _, status := range statuses {
|
|
if status.JobID == jobID {
|
|
return status, nil
|
|
}
|
|
}
|
|
return domain.DependencyStatus{}, repo.ErrNotFound
|
|
}
|
|
|
|
func (svc *CoreService) resolveRunUpdate(job domain.Job) (domain.RunUpdateJob, domain.RunDistribution, domain.Artifact, error) {
|
|
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
|
|
}
|
|
var update domain.RunUpdateJob
|
|
for _, candidate := range updates {
|
|
if candidate.JobID == job.ID {
|
|
update = candidate
|
|
break
|
|
}
|
|
}
|
|
if update.ID == "" || update.RunEndpointID != job.RunEndpointID {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update record does not match active job")
|
|
}
|
|
distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID, Status: domain.DistributionStatusAvailable})
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
|
|
}
|
|
var distribution domain.RunDistribution
|
|
for _, candidate := range distributions {
|
|
if candidate.ArtifactID == update.ArtifactID {
|
|
distribution = candidate
|
|
break
|
|
}
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
|
|
}
|
|
if endpoint.Platform != distribution.TargetOS || endpoint.Architecture != distribution.TargetArch {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update target no longer matches endpoint")
|
|
}
|
|
artifact, err := svc.store.Artifacts().Get(update.ArtifactID)
|
|
if err != nil {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, err
|
|
}
|
|
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID || artifact.Checksum != update.Checksum {
|
|
return domain.RunUpdateJob{}, domain.RunDistribution{}, domain.Artifact{}, validationError("Run update artifact is unavailable or outside distribution scope")
|
|
}
|
|
return update, distribution, artifact, nil
|
|
}
|
|
|
|
func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stamp time.Time) error {
|
|
if job.Capability == domain.JobCapabilityDependenciesCheck || job.Capability == domain.JobCapabilityDependenciesInstall {
|
|
status, err := svc.dependencyStatusForJob(job.ID, job.ServerInstanceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
status.UpdatedAt = stamp
|
|
status.CheckedAt = stamp
|
|
status.JobID = job.ID
|
|
if job.State == domain.JobStateSucceeded {
|
|
var evidence domain.DependencyExecutionEvidence
|
|
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil {
|
|
return validationError("dependency result evidence is invalid")
|
|
}
|
|
if evidence.ProbeKey != status.ProbeKey || evidence.PlanDigest != status.PlanDigest || job.ExecutionResult.Checksum != status.PlanDigest {
|
|
return validationError("dependency result evidence does not match approved plan")
|
|
}
|
|
status.State = domain.DependencyState(evidence.State)
|
|
status.Evidence = evidence.Evidence
|
|
status.CompletedSteps = evidence.CompletedSteps
|
|
status.Message = "dependency execution completed"
|
|
} else if job.State == domain.JobStateCancelled {
|
|
status.State = domain.DependencyStateFailed
|
|
status.Message = "dependency execution cancelled"
|
|
} else {
|
|
status.State = domain.DependencyStateFailed
|
|
status.Message = "dependency execution failed"
|
|
}
|
|
if err := validator.ValidateDependencyStatus(status); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.DependencyStatuses().Update(status); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if job.Capability != domain.JobCapabilityRunSelfUpdate {
|
|
return nil
|
|
}
|
|
update, _, _, err := svc.resolveRunUpdate(job)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
update.UpdatedAt = stamp
|
|
if job.State == domain.JobStateSucceeded {
|
|
var evidence domain.RunUpdateExecutionEvidence
|
|
if err := json.Unmarshal([]byte(job.ExecutionResult.Content), &evidence); err != nil || evidence.TargetRelease != update.TargetRelease || evidence.Phase != "staged" || job.ExecutionResult.Checksum != update.Checksum {
|
|
return validationError("Run update staged evidence is invalid")
|
|
}
|
|
update.Status = domain.DistributionJobStatusRunning
|
|
update.Phase = domain.RunUpdatePhaseRestartRequested
|
|
update.Message = "verified update staged; restart requested"
|
|
} else if job.State == domain.JobStateCancelled {
|
|
update.Status = domain.DistributionJobStatusFailed
|
|
update.Phase = domain.RunUpdatePhaseFailed
|
|
update.Message = "Run update cancelled before activation"
|
|
} else {
|
|
update.Status = domain.DistributionJobStatusFailed
|
|
update.Phase = domain.RunUpdatePhaseFailed
|
|
update.Message = "Run update verification or staging failed"
|
|
}
|
|
if err := validator.ValidateRunUpdateJob(update); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error {
|
|
if job.Capability == domain.JobCapabilityRunSelfUpdate {
|
|
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, update := range updates {
|
|
if update.JobID != job.ID || update.Status != domain.DistributionJobStatusQueued {
|
|
continue
|
|
}
|
|
update.Status = domain.DistributionJobStatusRunning
|
|
update.Phase = domain.RunUpdatePhaseDownloading
|
|
update.Message = "Run is downloading and verifying the update"
|
|
update.UpdatedAt = stamp
|
|
if err := validator.ValidateRunUpdateJob(update); err != nil {
|
|
return err
|
|
}
|
|
return svc.store.RunUpdateJobs().Update(update)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) {
|
|
if err := validator.ValidateRunUpdateHealthReport(report); err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
|
|
svc.jobMu.Lock()
|
|
defer svc.jobMu.Unlock()
|
|
job, err := svc.store.Jobs().Get(report.JobID)
|
|
if err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
if job.RunEndpointID != report.RunEndpointID || job.Capability != domain.JobCapabilityRunSelfUpdate || job.State != domain.JobStateSucceeded || job.Attempt != report.Attempt || !leaseTokenMatches(job.LeaseTokenHash, report.LeaseToken) {
|
|
return domain.RunUpdateHealthResult{}, validationError("Run update health report does not match terminal attempt")
|
|
}
|
|
updates, err := svc.store.RunUpdateJobs().List(domain.RunUpdateJobFilter{ServerInstanceID: job.ServerInstanceID})
|
|
if err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
var update domain.RunUpdateJob
|
|
for _, candidate := range updates {
|
|
if candidate.JobID == job.ID && candidate.RunEndpointID == report.RunEndpointID {
|
|
update = candidate
|
|
break
|
|
}
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
if endpoint.Version != report.Version || endpoint.Status != domain.RunEndpointStatusOnline {
|
|
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match online endpoint")
|
|
}
|
|
stamp := svc.now()
|
|
if report.Outcome == "succeeded" {
|
|
if report.Version != update.TargetRelease || update.Phase == domain.RunUpdatePhaseRolledBack || update.Phase == domain.RunUpdatePhaseFailed {
|
|
return domain.RunUpdateHealthResult{}, validationError("Run update health version does not match target release")
|
|
}
|
|
if update.Phase == domain.RunUpdatePhaseSucceeded {
|
|
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
|
|
}
|
|
update.Status = domain.DistributionJobStatusSucceeded
|
|
update.Phase = domain.RunUpdatePhaseSucceeded
|
|
update.Rollback = false
|
|
update.Message = "updated Run registered, reconciled, and reported healthy"
|
|
} else {
|
|
if update.PreviousVersion != "" && report.Version != update.PreviousVersion {
|
|
return domain.RunUpdateHealthResult{}, validationError("rolled-back Run version does not match previous release")
|
|
}
|
|
if update.Phase == domain.RunUpdatePhaseRolledBack {
|
|
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
|
|
}
|
|
update.Status = domain.DistributionJobStatusFailed
|
|
update.Phase = domain.RunUpdatePhaseRolledBack
|
|
update.Rollback = true
|
|
update.Message = "Run update activation failed and previous executable was restored"
|
|
}
|
|
update.UpdatedAt = stamp
|
|
if err := validator.ValidateRunUpdateJob(update); err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
if err := svc.store.RunUpdateJobs().Update(update); err != nil {
|
|
return domain.RunUpdateHealthResult{}, err
|
|
}
|
|
return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil
|
|
}
|
|
|
|
func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool {
|
|
return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey
|
|
}
|
|
|
|
func findRunDistributionForArtifact(distributions []domain.RunDistribution, artifactID string) (domain.RunDistribution, error) {
|
|
for _, distribution := range distributions {
|
|
if distribution.ArtifactID == artifactID && distribution.Status == domain.DistributionStatusAvailable {
|
|
return distribution, nil
|
|
}
|
|
}
|
|
return domain.RunDistribution{}, errors.New("available Run distribution not found")
|
|
}
|