feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) {
t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint)
}
if len(endpoint.Capabilities) != 2 || endpoint.Capacity.MaxJobs != 4 {
if len(endpoint.Capabilities) != 3 || endpoint.Capacity.MaxJobs != 4 {
t.Fatalf("expected capabilities and capacity, got %+v", endpoint)
}
}
@@ -224,7 +224,7 @@ func validRunControlHello() domain.RunControlHello {
Status: domain.RunEndpointStatusOnline,
Platform: "darwin/arm64",
CapabilityReport: domain.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"},
Capabilities: []string{"control.hello", "control.heartbeat", domain.JobCapabilityDistributionBuild},
Fingerprint: "cap-v1",
},
Capacity: domain.RunCapacity{MaxJobs: 4},
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) {
if err := validator.ValidateDistributionBuildInputRequest(request); err != nil {
return domain.DistributionBuildInput{}, err
}
if err := svc.validateRunSession(request.RunEndpointID, request.SessionToken); err != nil {
return domain.DistributionBuildInput{}, err
}
svc.jobMu.Lock()
job, _, err := svc.activeLeasedJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
svc.jobMu.Unlock()
if err != nil {
return domain.DistributionBuildInput{}, err
}
if job.Capability != domain.JobCapabilityDistributionBuild {
return domain.DistributionBuildInput{}, validationError("job is not a distribution build")
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.DistributionBuildInput{}, validationError("distribution build job is not active")
}
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 := 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,
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 := 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,
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) projectDistributionBuildProgress(job domain.Job, stamp time.Time) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return nil
}
builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, build := range builds {
if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued {
continue
}
build.Status = domain.DistributionJobStatusRunning
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
return svc.store.ClientManagerBuildJobs().Update(build)
}
return nil
}
func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp time.Time) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return nil
}
status := domain.DistributionStatusFailed
buildStatus := domain.DistributionJobStatusFailed
var artifact domain.Artifact
if job.State == domain.JobStateSucceeded {
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
if artifactID == "" || artifactID == job.ResultRef {
return validationError("distribution build result must reference an artifact")
}
var err error
artifact, err = svc.store.Artifacts().Get(artifactID)
if err != nil {
return err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
return validationError("distribution build artifact is unavailable or outside the job scope")
}
status = domain.DistributionStatusAvailable
buildStatus = domain.DistributionJobStatusSucceeded
}
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range runDistributions {
if distribution.BuildJobID != job.ID {
continue
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("distribution build returned an unexpected artifact")
}
distribution.Status = status
if artifact.ID != "" {
distribution.Checksum = artifact.Checksum
}
distribution.UpdatedAt = stamp
if err := validator.ValidateRunDistribution(distribution); err != nil {
return err
}
return svc.store.RunDistributions().Update(distribution)
}
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("client-manager build returned an unexpected artifact")
}
distribution.Status = status
if artifact.ID != "" {
distribution.Checksum = artifact.Checksum
}
distribution.UpdatedAt = stamp
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
return err
}
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
return err
}
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
if err == nil {
build.Status = buildStatus
if artifact.ID != "" {
build.ArtifactID = artifact.ID
build.Checksum = artifact.Checksum
}
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil {
return err
}
}
return nil
}
return repo.ErrNotFound
}
func executableFilename(base string, targetOS string) string {
if targetOS == "windows" {
return base + ".exe"
}
return base
}
+182 -83
View File
@@ -8,7 +8,6 @@ import (
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -78,6 +77,13 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
return domain.RunDistribution{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.RunDistribution{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.RunDistribution{}, err
}
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil {
@@ -90,26 +96,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.RunDistribution{}, err
}
config := generatedPackageConfig{
Kind: string(domain.DistributionComponentRun),
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
RunEndpointID: instance.RunEndpointID,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
SecretRef: key.SecretRef,
KeyGeneration: key.Generation,
AuthKey: plainKey,
}
payload, err := json.MarshalIndent(config, "", " ")
if err != nil {
return domain.RunDistribution{}, err
}
artifactID := artifactIDForDistribution(distributionID)
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
return domain.RunDistribution{}, err
}
_ = plainKey
artifactID := artifactIDForDistribution(distributionID + "-binary")
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
stamp := svc.now()
distribution := domain.RunDistribution{
ID: distributionID,
@@ -119,11 +108,11 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
PackageFormat: packageFormatForTarget(request.TargetOS),
ArtifactID: artifact.ID,
Checksum: artifact.Checksum,
BuildJobID: buildJobID,
ArtifactID: artifactID,
KeyGeneration: key.Generation,
SecretRef: key.SecretRef,
Status: domain.DistributionStatusAvailable,
Status: domain.DistributionStatusBuilding,
CreatedAt: stamp,
UpdatedAt: stamp,
}
@@ -131,9 +120,35 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.RunDistribution{}, err
}
if err := svc.store.RunDistributions().Create(distribution); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.RunDistributions().Get(distribution.ID)
if getErr != nil {
return domain.RunDistribution{}, getErr
}
return domain.CopyRunDistribution(existing), nil
}
return domain.RunDistribution{}, err
}
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultSuccess, "generated run package with redacted runtime key ref"); err != nil {
job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/run",
InputRef: "input://distribution-build/" + distribution.ID,
IdempotencyKey: "distribution-build:" + distribution.ID,
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
})
if err != nil {
distribution.Status = domain.DistributionStatusFailed
distribution.UpdatedAt = svc.now()
_ = svc.store.RunDistributions().Update(distribution)
return domain.RunDistribution{}, err
}
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
}
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job with redacted runtime key ref"); err != nil {
return domain.RunDistribution{}, err
}
return domain.CopyRunDistribution(distribution), nil
@@ -172,6 +187,13 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
return domain.ClientManagerDistribution{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.ClientManagerDistribution{}, err
}
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
if err != nil {
@@ -184,52 +206,12 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err
}
config := generatedPackageConfig{
Kind: string(domain.DistributionComponentClientManager),
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
ProfileKey: request.ProfileKey,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
SecretRef: key.SecretRef,
KeyGeneration: key.Generation,
AuthKey: plainKey,
}
checkout := clientManagerCheckoutPlan{
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
CheckoutRef: clientManagerCheckoutRef(request.RepositoryURL, request.SourceRevision),
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
}
outputArtifacts := []string{clientManagerOutputName(request.ProfileKey, request.TargetOS)}
buildLogPayload := []byte(clientManagerBuildLog(checkout, config, outputArtifacts))
buildLogArtifactID := artifactIDForDistribution(clientDistributionID + "-build-log")
buildLogArtifact, err := svc.createPlatformArtifactPayload(buildLogArtifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, buildLogPayload)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
packagePayload := generatedClientManagerPackage{
Kind: "client-manager-package",
Checkout: checkout,
Config: config,
OutputArtifacts: outputArtifacts,
BuildLogRef: "artifact://" + buildLogArtifact.ID,
KeyFingerprint: fingerprintForString(plainKey),
}
payload, err := json.MarshalIndent(packagePayload, "", " ")
if err != nil {
return domain.ClientManagerDistribution{}, err
}
artifactID := artifactIDForDistribution(clientDistributionID)
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
_ = plainKey
artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
stamp := svc.now()
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
buildJob := domain.ClientManagerBuildJob{
ID: distributionID("client-manager-build", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey),
ID: buildJobID,
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
ProfileKey: request.ProfileKey,
@@ -237,11 +219,9 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
TargetArch: request.TargetArch,
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
ArtifactID: artifact.ID,
Checksum: artifact.Checksum,
ArtifactID: artifactID,
KeyGeneration: key.Generation,
LogsRef: "artifact://" + buildLogArtifact.ID,
Status: domain.DistributionJobStatusSucceeded,
Status: domain.DistributionJobStatusQueued,
CreatedAt: stamp,
UpdatedAt: stamp,
}
@@ -249,7 +229,17 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err
}
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
return domain.ClientManagerDistribution{}, err
if !errors.Is(err, repo.ErrDuplicate) {
return domain.ClientManagerDistribution{}, err
}
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
}
buildJob = existing
}
distribution := domain.ClientManagerDistribution{
ID: clientDistributionID,
@@ -261,11 +251,10 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
BuildJobID: buildJob.ID,
ArtifactID: artifact.ID,
Checksum: artifact.Checksum,
ArtifactID: artifactID,
KeyGeneration: key.Generation,
SecretRef: key.SecretRef,
Status: domain.DistributionStatusAvailable,
Status: domain.DistributionStatusBuilding,
CreatedAt: stamp,
UpdatedAt: stamp,
}
@@ -273,9 +262,38 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err
}
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
return domain.CopyClientManagerDistribution(existing), nil
}
return domain.ClientManagerDistribution{}, err
}
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultSuccess, "generated client-manager artifact with redacted runtime key ref"); err != nil {
job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/client-manager/" + request.ProfileKey,
InputRef: "input://distribution-build/" + distribution.ID,
IdempotencyKey: "distribution-build:" + distribution.ID,
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
})
if err != nil {
buildJob.Status = domain.DistributionJobStatusFailed
buildJob.UpdatedAt = svc.now()
distribution.Status = domain.DistributionStatusFailed
distribution.UpdatedAt = buildJob.UpdatedAt
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
_ = svc.store.ClientManagerDistributions().Update(distribution)
return domain.ClientManagerDistribution{}, err
}
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
}
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build with redacted runtime key ref"); err != nil {
return domain.ClientManagerDistribution{}, err
}
return domain.CopyClientManagerDistribution(distribution), nil
@@ -458,11 +476,11 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
RunEndpointID: endpoint.ID,
RunStatus: endpoint.Status,
Actions: []domain.ServerRuntimeAction{
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared", bindingReason)),
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", bindingReason)),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
@@ -546,6 +564,16 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
return domain.RunUpdateJob{}, err
}
if err := svc.store.RunUpdateJobs().Create(updateJob); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.RunUpdateJobs().Get(updateJob.ID)
if getErr != nil {
return domain.RunUpdateJob{}, getErr
}
if !sameRunUpdateJob(existing, updateJob) {
return domain.RunUpdateJob{}, validationError("run update job already exists with different target")
}
return domain.CopyRunUpdateJob(existing), nil
}
return domain.RunUpdateJob{}, err
}
if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil {
@@ -793,6 +821,9 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki
func (svc *CoreService) expireDistributionArtifact(artifactID string) error {
artifact, err := svc.store.Artifacts().Get(artifactID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
@@ -819,14 +850,82 @@ func (svc *CoreService) createPlatformArtifactPayload(artifactID string, ownerKi
return domain.Artifact{}, err
}
if err := svc.store.Artifacts().Create(artifact); err != nil {
if !errors.Is(err, repo.ErrDuplicate) {
return domain.Artifact{}, err
}
existing, getErr := svc.store.Artifacts().Get(artifact.ID)
if getErr != nil {
return domain.Artifact{}, getErr
}
if err := validateReusablePlatformArtifact(existing, artifact); err != nil {
return domain.Artifact{}, err
}
if err := svc.ensureArtifactPayload(artifact.ID, payload, existing); err != nil {
return domain.Artifact{}, err
}
return domain.CopyArtifact(existing), nil
}
if err := svc.ensureArtifactPayload(artifact.ID, payload, artifact); err != nil {
return domain.Artifact{}, err
}
svc.artifactMu.Lock()
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
svc.artifactMu.Unlock()
return domain.CopyArtifact(artifact), nil
}
func validateReusablePlatformArtifact(existing domain.Artifact, expected domain.Artifact) error {
if existing.OwnerKind != expected.OwnerKind || existing.OwnerID != expected.OwnerID {
return validationError("artifact already exists with different owner")
}
if existing.SizeBytes != expected.SizeBytes || existing.Checksum != expected.Checksum {
return validationError("artifact already exists with different content")
}
if existing.State != domain.ArtifactStateAvailable {
return validationError("artifact already exists but is not available")
}
return nil
}
func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, artifact domain.Artifact) error {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if existingPayload, exists := svc.artifactPayloads[artifactID]; exists {
if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
return nil
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return nil
}
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID &&
existing.PluginID == expected.PluginID &&
existing.ProfileKey == expected.ProfileKey &&
existing.TargetOS == expected.TargetOS &&
existing.TargetArch == expected.TargetArch &&
existing.RepositoryURL == expected.RepositoryURL &&
existing.SourceRevision == expected.SourceRevision &&
existing.ArtifactID == expected.ArtifactID &&
existing.Checksum == expected.Checksum &&
existing.KeyGeneration == expected.KeyGeneration &&
existing.LogsRef == expected.LogsRef &&
existing.Status == expected.Status
}
func sameRunUpdateJob(existing domain.RunUpdateJob, expected domain.RunUpdateJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID &&
existing.RunEndpointID == expected.RunEndpointID &&
existing.ArtifactID == expected.ArtifactID &&
existing.Checksum == expected.Checksum &&
existing.JobID == expected.JobID &&
existing.IdempotencyKey == expected.IdempotencyKey &&
existing.Status == expected.Status
}
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error {
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
stamp := svc.now()
+167 -49
View File
@@ -1,12 +1,12 @@
package service
import (
"encoding/json"
"errors"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
@@ -21,9 +21,16 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusAvailable {
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" || distribution.Checksum != "" {
t.Fatalf("unexpected run distribution: %+v", distribution)
}
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.Capability != domain.JobCapabilityDistributionBuild || job.State != domain.JobStateQueued {
t.Fatalf("expected queued backend build job, job=%+v err=%v", job, err)
}
if _, err := svc.GetArtifact(distribution.ArtifactID); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("generation must not publish config JSON as an artifact, got %v", err)
}
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
ServerInstanceID: instance.ID,
@@ -76,6 +83,78 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
}
}
func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("ensure run key: %v", err)
}
idempotencyKey := "web:run.generate:scum-alpha:1784043453685"
distributionID := distributionID("run-dist", instance.ID, "windows", "amd64", key.Generation, idempotencyKey)
artifactID := artifactIDForDistribution(distributionID)
payload := []byte("legacy generated config is not a binary distribution")
partialArtifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
t.Fatalf("create partial artifact: %v", err)
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: idempotencyKey,
})
if err != nil {
t.Fatalf("retry run generation should recover partial artifact: %v", err)
}
if distribution.ID != distributionID || distribution.ArtifactID == partialArtifact.ID || distribution.Checksum != "" {
t.Fatalf("expected real binary build to ignore legacy config artifact, distribution=%+v artifact=%+v", distribution, partialArtifact)
}
if distribution.PackageFormat != "zip" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" {
t.Fatalf("unexpected recovered distribution: %+v", distribution)
}
recoveredConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
if recoveredConfig.AuthKey != plainKey || recoveredConfig.KeyGeneration != key.Generation {
t.Fatalf("expected recovered artifact payload to remain downloadable, got %+v", recoveredConfig)
}
}
func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: "idem-run-before-update",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive"))
request := domain.RunUpdateRequest{
ServerInstanceID: instance.ID,
ArtifactID: distribution.ArtifactID,
Checksum: distribution.Checksum,
IdempotencyKey: "idem-run-update-repeat",
}
first, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push run update: %v", err)
}
second, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push run update again should reuse existing update job: %v", err)
}
if second.ID != first.ID || second.JobID != first.JobID || second.ArtifactID != first.ArtifactID {
t.Fatalf("expected repeated push to return existing update job, first=%+v second=%+v", first, second)
}
if second.Status != domain.DistributionJobStatusQueued {
t.Fatalf("expected queued existing update job, got %+v", second)
}
}
func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
@@ -88,6 +167,7 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
t.Fatalf("generate run distribution: %v", err)
}
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive before reset"))
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID,
@@ -161,6 +241,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
t.Fatalf("open run download: %v", err)
}
@@ -179,7 +260,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
t.Fatalf("generate client-manager distribution: %v", err)
}
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusAvailable {
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
}
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
@@ -189,29 +270,13 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
if err != nil {
t.Fatalf("get build job: %v", err)
}
if build.Status != domain.DistributionJobStatusSucceeded || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
t.Fatalf("unexpected build job: %+v", build)
}
if build.LogsRef == "" || !strings.HasPrefix(build.LogsRef, "artifact://") {
t.Fatalf("expected redacted build log artifact ref, got %+v", build)
}
packagePayload := readClientManagerPackage(t, svc, session, clientDistribution.ArtifactID)
if packagePayload.Checkout.CheckoutRef != "branch/main" || packagePayload.Config.AuthKey != clientConfig.AuthKey || packagePayload.KeyFingerprint == "" {
t.Fatalf("expected package checkout metadata and injected config, got %+v", packagePayload)
}
if len(packagePayload.OutputArtifacts) == 0 || packagePayload.BuildLogRef != build.LogsRef {
t.Fatalf("expected output artifacts and build log ref, got %+v build=%+v", packagePayload, build)
}
buildLog := readArtifactString(t, svc, session, strings.TrimPrefix(build.LogsRef, "artifact://"))
for _, expected := range []string{"client-manager checkout prepared", "checkoutRef=branch/main", "dependencyCheck=typed build profile accepted", "configInjection=secret ref"} {
if !strings.Contains(buildLog, expected) {
t.Fatalf("expected build log to contain %q, got %q", expected, buildLog)
}
}
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(buildLog, forbidden) {
t.Fatalf("build log leaked forbidden fragment %q: %s", forbidden, buildLog)
}
clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
}
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
@@ -293,6 +358,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
t.Fatalf("update plugin fixture: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
@@ -323,47 +389,99 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
_ = session
runs, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
if err != nil {
t.Fatalf("read artifact content: %v", err)
t.Fatalf("list run distributions: %v", err)
}
var config generatedPackageConfig
if err := json.Unmarshal(content.Payload, &config); err != nil {
t.Fatalf("unmarshal generated config: %v", err)
}
if config.AuthKey == "" {
var packagePayload generatedClientManagerPackage
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err)
for _, distribution := range runs {
if distribution.ArtifactID != artifactID {
continue
}
config = packagePayload.Config
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("get run key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt run key: %v", err)
}
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
if config.AuthKey == "" || config.SecretRef == "" || config.KeyGeneration <= 0 {
t.Fatalf("generated package config is incomplete: %+v", config)
clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
if err != nil {
t.Fatalf("list client distributions: %v", err)
}
return config
for _, distribution := range clients {
if distribution.ArtifactID != artifactID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
t.Fatalf("get client key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt client key: %v", err)
}
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
t.Fatalf("distribution for artifact %s was not found", artifactID)
return generatedPackageConfig{}
}
func readClientManagerPackage(t *testing.T, svc *CoreService, session string, artifactID string) generatedClientManagerPackage {
func completeDistributionBuild(t *testing.T, svc *CoreService, distribution domain.RunDistribution, payload []byte) domain.RunDistribution {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
if err != nil {
t.Fatalf("read client-manager package content: %v", err)
t.Fatalf("publish run build artifact: %v", err)
}
var packagePayload generatedClientManagerPackage
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err)
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get run build job: %v", err)
}
return packagePayload
job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update run build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project run build: %v", err)
}
updated, err := svc.store.RunDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get completed run distribution: %v", err)
}
return updated
}
func readArtifactString(t *testing.T, svc *CoreService, session string, artifactID string) string {
func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, payload []byte) domain.ClientManagerDistribution {
t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID})
artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
if err != nil {
t.Fatalf("read artifact content: %v", err)
t.Fatalf("publish client build artifact: %v", err)
}
return string(content.Payload)
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get client build job: %v", err)
}
job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update client build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project client build: %v", err)
}
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get completed client distribution: %v", err)
}
return updated
}
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
+9
View File
@@ -126,6 +126,9 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobProgressResult{}, err
}
if err := svc.projectDistributionBuildProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err
}
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
@@ -153,6 +156,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result")
@@ -171,6 +177,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
lease.TerminalFingerprint = fingerprint
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
+2 -1
View File
@@ -86,6 +86,7 @@ type Core interface {
AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error)
UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error)
CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error)
GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error)
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error)
@@ -1879,7 +1880,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
if plugin.ID != instance.PluginID {
return validationError("job plugin must match server instance")
}
if !containsString(plugin.RequiredRunCapabilities, job.Capability) {
if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
return validationError("plugin missing required capability: " + job.Capability)
}
return nil