first commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
type AIProviderClient interface {
|
||||
Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error)
|
||||
}
|
||||
|
||||
type MockAIProviderClient struct{}
|
||||
|
||||
func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
||||
model := request.Model
|
||||
if model == "" {
|
||||
model = provider.DefaultModel
|
||||
}
|
||||
if model == "" && len(provider.Models) > 0 {
|
||||
model = provider.Models[0]
|
||||
}
|
||||
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
|
||||
result := domain.AIProviderInvocationResult{
|
||||
Recommendation: recommendation,
|
||||
Usage: domain.AIInvocationUsage{
|
||||
ProviderID: provider.ID,
|
||||
Model: model,
|
||||
InputTokens: boundedTokenEstimate(request.Prompt + request.CurrentConfig),
|
||||
OutputTokens: boundedTokenEstimate(recommendation),
|
||||
Mocked: true,
|
||||
},
|
||||
}
|
||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
||||
result.SuggestedConfig = buildSuggestedConfig(request.CurrentConfig, request.Prompt)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIInvocationRequest) (domain.AIInvocationResponse, error) {
|
||||
request = domain.CopyAIInvocationRequest(request)
|
||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if request.ServerInstanceID != "" {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if request.PluginID != "" && instance.PluginID != request.PluginID {
|
||||
return safeAIDenial(request, "plugin scope does not match server instance"), nil
|
||||
}
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{
|
||||
PluginID: request.PluginID,
|
||||
RouteKey: request.RouteKey,
|
||||
ServerInstanceID: request.ServerInstanceID,
|
||||
Action: domain.PluginBridgeActionAIInvoke,
|
||||
AIPurpose: request.Purpose,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
if !authorization.Allowed {
|
||||
return safeAIDenial(request, authorization.Reason), nil
|
||||
}
|
||||
}
|
||||
provider, err := svc.selectAIProvider(request.ProviderID)
|
||||
if err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
result, err := svc.aiProviderClient.Invoke(provider, request)
|
||||
if err != nil {
|
||||
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
||||
RequestID: request.RequestID,
|
||||
Purpose: request.Purpose,
|
||||
ProviderID: provider.ID,
|
||||
Model: safeModel(request.Model, provider),
|
||||
Status: "error",
|
||||
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
|
||||
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
|
||||
}), nil
|
||||
}
|
||||
response := domain.AIInvocationResponse{
|
||||
RequestID: request.RequestID,
|
||||
Purpose: request.Purpose,
|
||||
ProviderID: provider.ID,
|
||||
Model: result.Usage.Model,
|
||||
Status: "ok",
|
||||
Recommendation: result.Recommendation,
|
||||
Usage: result.Usage,
|
||||
}
|
||||
if result.SuggestedConfig != "" {
|
||||
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
|
||||
}
|
||||
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||
return domain.AIInvocationResponse{}, err
|
||||
}
|
||||
return domain.CopyAIInvocationResponse(response), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) selectAIProvider(id string) (domain.AIProvider, error) {
|
||||
if id != "" {
|
||||
provider, err := svc.store.AIProviders().Get(id)
|
||||
if err != nil {
|
||||
return domain.AIProvider{}, err
|
||||
}
|
||||
if provider.Status != domain.AIProviderStatusActive {
|
||||
return domain.AIProvider{}, ErrForbidden
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
providers, err := svc.store.AIProviders().List(domain.AIProviderFilter{Status: domain.AIProviderStatusActive})
|
||||
if err != nil {
|
||||
return domain.AIProvider{}, err
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return domain.AIProvider{}, repo.ErrNotFound
|
||||
}
|
||||
return providers[0], nil
|
||||
}
|
||||
|
||||
func safeAIDenial(request domain.AIInvocationRequest, reason string) domain.AIInvocationResponse {
|
||||
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
|
||||
RequestID: request.RequestID,
|
||||
Purpose: request.Purpose,
|
||||
Status: "denied",
|
||||
Error: &domain.AIInvocationSafeError{Code: "permission_denied", Message: safeBridgeReason(reason)},
|
||||
})
|
||||
}
|
||||
|
||||
func safeModel(model string, provider domain.AIProvider) string {
|
||||
if strings.TrimSpace(model) != "" {
|
||||
return model
|
||||
}
|
||||
if provider.DefaultModel != "" {
|
||||
return provider.DefaultModel
|
||||
}
|
||||
if len(provider.Models) > 0 {
|
||||
return provider.Models[0]
|
||||
}
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
func buildSuggestedConfig(currentConfig string, prompt string) string {
|
||||
base := strings.TrimRight(currentConfig, "\n")
|
||||
if base == "" {
|
||||
base = "# generated server config"
|
||||
}
|
||||
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
|
||||
base += "\npvp=false"
|
||||
}
|
||||
return base + "\n# ai.recommendation=review-required\n"
|
||||
}
|
||||
|
||||
func boundedTokenEstimate(value string) int {
|
||||
count := len([]rune(value)) / 4
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
if count > 4096 {
|
||||
return 4096
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type failingAIProviderClient struct{ err error }
|
||||
|
||||
func (client failingAIProviderClient) Invoke(domain.AIProvider, domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
|
||||
if client.err == nil {
|
||||
return domain.AIProviderInvocationResult{}, errors.New("provider failed")
|
||||
}
|
||||
return domain.AIProviderInvocationResult{}, client.err
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const artifactDownloadStorageBehavior = "platform-memory-transfer-session"
|
||||
|
||||
func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) {
|
||||
artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID))
|
||||
if err != nil {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
if err := svc.authorizeArtifactAccess(sessionID, artifact); err != nil {
|
||||
return domain.Artifact{}, err
|
||||
}
|
||||
return domain.CopyArtifact(artifact), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) {
|
||||
request = domain.CopyArtifactDownloadReferenceRequest(request)
|
||||
if err := validator.ValidateArtifactDownloadReferenceRequest(request); err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
|
||||
if err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.ArtifactDownloadReference{}, validationError("artifact must be available before download")
|
||||
}
|
||||
|
||||
reference := domain.ArtifactDownloadReference{
|
||||
ArtifactID: artifact.ID,
|
||||
OwnerKind: artifact.OwnerKind,
|
||||
OwnerID: artifact.OwnerID,
|
||||
Filename: artifactDownloadFilename(artifact.ID),
|
||||
ContentType: "application/octet-stream",
|
||||
SizeBytes: artifact.SizeBytes,
|
||||
Checksum: artifact.Checksum,
|
||||
State: artifact.State,
|
||||
DownloadURL: "/api/v1/artifacts/" + url.PathEscape(artifact.ID) + "/content",
|
||||
ExpiresAt: svc.now().Add(15 * time.Minute),
|
||||
RangeSupported: true,
|
||||
ChunkSizeBytes: validator.MaxArtifactDownloadBytes,
|
||||
StorageBehavior: artifactDownloadStorageBehavior,
|
||||
}
|
||||
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
|
||||
return domain.ArtifactDownloadReference{}, err
|
||||
}
|
||||
return domain.CopyArtifactDownloadReference(reference), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request domain.ArtifactContentRequest) (domain.ArtifactContent, error) {
|
||||
request = domain.CopyArtifactContentRequest(request)
|
||||
if err := validator.ValidateArtifactContentRequest(request); err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
|
||||
if err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.ArtifactContent{}, validationError("artifact must be available before download")
|
||||
}
|
||||
payload, err := svc.artifactPayload(artifact.ID)
|
||||
if err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
if int64(len(payload)) != artifact.SizeBytes {
|
||||
return domain.ArtifactContent{}, validationError("artifact content size does not match metadata")
|
||||
}
|
||||
if checksum := validator.BytesChecksum(payload); checksum != artifact.Checksum {
|
||||
return domain.ArtifactContent{}, validationError("artifact content checksum does not match metadata")
|
||||
}
|
||||
if request.Offset >= artifact.SizeBytes {
|
||||
return domain.ArtifactContent{}, validationError("offset must be inside artifact content")
|
||||
}
|
||||
limit := request.Limit
|
||||
if limit == 0 {
|
||||
limit = validator.MaxArtifactDownloadBytes
|
||||
}
|
||||
remaining := artifact.SizeBytes - request.Offset
|
||||
if int64(limit) > remaining {
|
||||
limit = int(remaining)
|
||||
}
|
||||
end := int(request.Offset) + limit
|
||||
part := domain.CopyBytes(payload[int(request.Offset):end])
|
||||
content := domain.ArtifactContent{
|
||||
ArtifactID: artifact.ID,
|
||||
Filename: artifactDownloadFilename(artifact.ID),
|
||||
ContentType: "application/octet-stream",
|
||||
Offset: request.Offset,
|
||||
SizeBytes: int64(len(part)),
|
||||
TotalSizeBytes: artifact.SizeBytes,
|
||||
Checksum: artifact.Checksum,
|
||||
ContentChecksum: validator.BytesChecksum(part),
|
||||
Partial: request.Offset != 0 || int64(len(part)) != artifact.SizeBytes,
|
||||
RangeSupported: true,
|
||||
Payload: part,
|
||||
StorageBehavior: artifactDownloadStorageBehavior,
|
||||
ServedAt: svc.now(),
|
||||
}
|
||||
if err := validator.ValidateArtifactContent(content); err != nil {
|
||||
return domain.ArtifactContent{}, err
|
||||
}
|
||||
return domain.CopyArtifactContent(content), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) authorizeArtifactAccess(sessionID string, artifact domain.Artifact) error {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch artifact.OwnerKind {
|
||||
case domain.ArtifactOwnerKindJob:
|
||||
job, err := svc.store.Jobs().Get(artifact.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return ErrForbidden
|
||||
}
|
||||
case domain.ArtifactOwnerKindServerInstance:
|
||||
instance, err := svc.store.ServerInstances().Get(artifact.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return ErrForbidden
|
||||
}
|
||||
case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin:
|
||||
if !isPlatformAdmin(user) {
|
||||
return ErrForbidden
|
||||
}
|
||||
default:
|
||||
return validationError("artifact ownerKind is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
|
||||
for _, session := range svc.artifactTransfers {
|
||||
if session.ArtifactID == artifactID && session.Completed {
|
||||
sessions = append(sessions, domain.CopyArtifactTransferSession(session))
|
||||
}
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
return nil, validationError("artifact content is not available from platform storage")
|
||||
}
|
||||
sort.Slice(sessions, func(i int, j int) bool { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) })
|
||||
session := sessions[0]
|
||||
payload := make([]byte, 0, int(session.SizeBytes))
|
||||
for index := 0; index < session.TotalChunks; index++ {
|
||||
record, exists := session.ReceivedChunks[index]
|
||||
if !exists {
|
||||
return nil, validationError("artifact content has missing chunks")
|
||||
}
|
||||
payload = append(payload, record.Payload...)
|
||||
}
|
||||
if int64(len(payload)) != session.SizeBytes {
|
||||
return nil, validationError("artifact content size does not match transfer")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func artifactDownloadFilename(artifactID string) string {
|
||||
name := strings.TrimSpace(artifactID)
|
||||
if name == "" || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "://") {
|
||||
return "artifact.bin"
|
||||
}
|
||||
return fmt.Sprintf("%s.bin", name)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) OpenArtifactTransfer(open domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) {
|
||||
open = domain.CopyArtifactTransferOpen(open)
|
||||
if err := validator.ValidateArtifactTransferOpen(open); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(open.RunEndpointID, open.SessionToken); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
if session, exists := svc.findArtifactTransferByIdempotency(open.RunEndpointID, open.IdempotencyKey); exists {
|
||||
if err := validateArtifactTransferOpenMatchesSession(open, session); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
artifact, err := svc.store.Artifacts().Get(session.ArtifactID)
|
||||
if err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
return artifactTransferOpenResult(session, artifact, true, stamp), nil
|
||||
}
|
||||
|
||||
if err := svc.validateArtifactTransferOwner(open); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
|
||||
artifact, err := svc.store.Artifacts().Get(open.ArtifactID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, repo.ErrNotFound) {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
artifact = domain.Artifact{
|
||||
ID: open.ArtifactID,
|
||||
OwnerKind: open.OwnerKind,
|
||||
OwnerID: open.OwnerID,
|
||||
SizeBytes: open.SizeBytes,
|
||||
Checksum: open.Checksum,
|
||||
State: domain.ArtifactStateUploading,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
} else if err := validateArtifactMatchesTransferOpen(artifact, open); err != nil {
|
||||
return domain.ArtifactTransferOpenResult{}, err
|
||||
}
|
||||
|
||||
svc.artifactTransferSeq++
|
||||
session := domain.ArtifactTransferSession{
|
||||
TransferID: fmt.Sprintf("artifact-transfer:%s:%d:%d", open.ArtifactID, stamp.UnixNano(), svc.artifactTransferSeq),
|
||||
RunEndpointID: open.RunEndpointID,
|
||||
ArtifactID: open.ArtifactID,
|
||||
Direction: open.Direction,
|
||||
OwnerKind: open.OwnerKind,
|
||||
OwnerID: open.OwnerID,
|
||||
SizeBytes: open.SizeBytes,
|
||||
ChunkSizeBytes: open.ChunkSizeBytes,
|
||||
Checksum: open.Checksum,
|
||||
IdempotencyKey: open.IdempotencyKey,
|
||||
TotalChunks: artifactTotalChunks(open.SizeBytes, open.ChunkSizeBytes),
|
||||
ReceivedChunks: map[int]domain.ArtifactChunkRecord{},
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
|
||||
return artifactTransferOpenResult(session, artifact, false, stamp), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadArtifactChunk(chunk domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error) {
|
||||
chunk = domain.CopyArtifactChunkUpload(chunk)
|
||||
if err := validator.ValidateArtifactChunkUpload(chunk); err != nil {
|
||||
return domain.ArtifactChunkUploadResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(chunk.RunEndpointID, chunk.SessionToken); err != nil {
|
||||
return domain.ArtifactChunkUploadResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
session, err := svc.getArtifactTransferSession(chunk.TransferID)
|
||||
if err != nil {
|
||||
return domain.ArtifactChunkUploadResult{}, err
|
||||
}
|
||||
if err := validateArtifactChunkMatchesSession(chunk, session); err != nil {
|
||||
return domain.ArtifactChunkUploadResult{}, err
|
||||
}
|
||||
|
||||
if existing, exists := session.ReceivedChunks[chunk.ChunkIndex]; exists {
|
||||
if existing.Offset == chunk.Offset && existing.SizeBytes == chunk.SizeBytes && existing.Checksum == chunk.Checksum && bytes.Equal(existing.Payload, chunk.Payload) {
|
||||
return artifactChunkUploadResult(session, chunk.ChunkIndex, true, stamp), nil
|
||||
}
|
||||
return domain.ArtifactChunkUploadResult{}, validationError("artifact chunk conflicts with acknowledged chunk")
|
||||
}
|
||||
|
||||
session.ReceivedChunks[chunk.ChunkIndex] = domain.ArtifactChunkRecord{
|
||||
ChunkIndex: chunk.ChunkIndex,
|
||||
Offset: chunk.Offset,
|
||||
SizeBytes: chunk.SizeBytes,
|
||||
Checksum: chunk.Checksum,
|
||||
Payload: domain.CopyBytes(chunk.Payload),
|
||||
ReceivedAt: stamp,
|
||||
}
|
||||
session.UpdatedAt = stamp
|
||||
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
|
||||
return artifactChunkUploadResult(session, chunk.ChunkIndex, false, stamp), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryArtifactTransferStatus(query domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error) {
|
||||
if err := validator.ValidateArtifactTransferStatusQuery(query); err != nil {
|
||||
return domain.ArtifactTransferStatusResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(query.RunEndpointID, query.SessionToken); err != nil {
|
||||
return domain.ArtifactTransferStatusResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
session, err := svc.getArtifactTransferSession(query.TransferID)
|
||||
if err != nil {
|
||||
return domain.ArtifactTransferStatusResult{}, err
|
||||
}
|
||||
if err := validateArtifactTransferStatusMatchesSession(query, session); err != nil {
|
||||
return domain.ArtifactTransferStatusResult{}, err
|
||||
}
|
||||
return artifactTransferStatusResult(session, stamp), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CompleteArtifactTransfer(complete domain.ArtifactTransferComplete) (domain.ArtifactTransferCompleteResult, error) {
|
||||
if err := validator.ValidateArtifactTransferComplete(complete); err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(complete.RunEndpointID, complete.SessionToken); err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
|
||||
session, err := svc.getArtifactTransferSession(complete.TransferID)
|
||||
if err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
if err := validateArtifactCompleteMatchesSession(complete, session); err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
artifact, err := svc.store.Artifacts().Get(session.ArtifactID)
|
||||
if err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
if session.Completed {
|
||||
return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil
|
||||
}
|
||||
if len(session.ReceivedChunks) != session.TotalChunks {
|
||||
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks")
|
||||
}
|
||||
|
||||
payload := make([]byte, 0, int(session.SizeBytes))
|
||||
for index := 0; index < session.TotalChunks; index++ {
|
||||
record, exists := session.ReceivedChunks[index]
|
||||
if !exists {
|
||||
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks")
|
||||
}
|
||||
payload = append(payload, record.Payload...)
|
||||
}
|
||||
if int64(len(payload)) != session.SizeBytes {
|
||||
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer size does not match metadata")
|
||||
}
|
||||
if checksum := validator.BytesChecksum(payload); checksum != session.Checksum {
|
||||
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer checksum does not match metadata")
|
||||
}
|
||||
|
||||
artifact.SizeBytes = session.SizeBytes
|
||||
artifact.Checksum = session.Checksum
|
||||
artifact.State = domain.ArtifactStateAvailable
|
||||
artifact.UpdatedAt = stamp
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
if err := svc.store.Artifacts().Update(artifact); err != nil {
|
||||
return domain.ArtifactTransferCompleteResult{}, err
|
||||
}
|
||||
session.Completed = true
|
||||
session.UpdatedAt = stamp
|
||||
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
|
||||
return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) getArtifactTransferSession(transferID string) (domain.ArtifactTransferSession, error) {
|
||||
session, exists := svc.artifactTransfers[transferID]
|
||||
if !exists {
|
||||
return domain.ArtifactTransferSession{}, repo.ErrNotFound
|
||||
}
|
||||
return domain.CopyArtifactTransferSession(session), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) findArtifactTransferByIdempotency(runEndpointID string, idempotencyKey string) (domain.ArtifactTransferSession, bool) {
|
||||
transferIDs := make([]string, 0, len(svc.artifactTransfers))
|
||||
for transferID := range svc.artifactTransfers {
|
||||
transferIDs = append(transferIDs, transferID)
|
||||
}
|
||||
sort.Strings(transferIDs)
|
||||
for _, transferID := range transferIDs {
|
||||
session := svc.artifactTransfers[transferID]
|
||||
if session.RunEndpointID == runEndpointID && session.IdempotencyKey == idempotencyKey {
|
||||
return domain.CopyArtifactTransferSession(session), true
|
||||
}
|
||||
}
|
||||
return domain.ArtifactTransferSession{}, false
|
||||
}
|
||||
|
||||
func (svc *CoreService) validateArtifactTransferOwner(open domain.ArtifactTransferOpen) error {
|
||||
switch open.OwnerKind {
|
||||
case domain.ArtifactOwnerKindJob:
|
||||
job, err := svc.store.Jobs().Get(open.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job.RunEndpointID != open.RunEndpointID {
|
||||
return validationError("artifact owner job must belong to runEndpointId")
|
||||
}
|
||||
case domain.ArtifactOwnerKindServerInstance:
|
||||
instance, err := svc.store.ServerInstances().Get(open.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instance.RunEndpointID != open.RunEndpointID {
|
||||
return validationError("artifact owner server instance must belong to runEndpointId")
|
||||
}
|
||||
if instance.State == domain.ServerInstanceStateDeleted {
|
||||
return validationError("artifact owner server instance must not be deleted")
|
||||
}
|
||||
default:
|
||||
return validationError("ownerKind must be job or server-instance for run uploads")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifactMatchesTransferOpen(artifact domain.Artifact, open domain.ArtifactTransferOpen) error {
|
||||
if artifact.OwnerKind != open.OwnerKind {
|
||||
return validationError("artifact ownerKind must match transfer")
|
||||
}
|
||||
if artifact.OwnerID != open.OwnerID {
|
||||
return validationError("artifact ownerId must match transfer")
|
||||
}
|
||||
if artifact.SizeBytes != open.SizeBytes {
|
||||
return validationError("artifact sizeBytes must match transfer")
|
||||
}
|
||||
if artifact.Checksum != open.Checksum {
|
||||
return validationError("artifact checksum must match transfer")
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateUploading {
|
||||
return validationError("artifact must be uploading")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifactTransferOpenMatchesSession(open domain.ArtifactTransferOpen, session domain.ArtifactTransferSession) error {
|
||||
if session.ArtifactID != open.ArtifactID || session.Direction != open.Direction || session.OwnerKind != open.OwnerKind || session.OwnerID != open.OwnerID || session.SizeBytes != open.SizeBytes || session.ChunkSizeBytes != open.ChunkSizeBytes || session.Checksum != open.Checksum {
|
||||
return validationError("artifact transfer idempotency key conflicts with existing transfer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifactChunkMatchesSession(chunk domain.ArtifactChunkUpload, session domain.ArtifactTransferSession) error {
|
||||
if session.Completed {
|
||||
return validationError("artifact transfer is already complete")
|
||||
}
|
||||
if session.RunEndpointID != chunk.RunEndpointID {
|
||||
return validationError("runEndpointId must match artifact transfer")
|
||||
}
|
||||
if session.ArtifactID != chunk.ArtifactID {
|
||||
return validationError("artifactId must match artifact transfer")
|
||||
}
|
||||
if chunk.ChunkIndex >= session.TotalChunks {
|
||||
return validationError("chunkIndex exceeds transfer chunk count")
|
||||
}
|
||||
expectedOffset := int64(chunk.ChunkIndex) * int64(session.ChunkSizeBytes)
|
||||
if chunk.Offset != expectedOffset {
|
||||
return validationError("offset must match chunk index")
|
||||
}
|
||||
expectedSize := expectedChunkSize(session, chunk.ChunkIndex)
|
||||
if chunk.SizeBytes != expectedSize {
|
||||
return validationError("sizeBytes must match expected chunk size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifactTransferStatusMatchesSession(query domain.ArtifactTransferStatusQuery, session domain.ArtifactTransferSession) error {
|
||||
if session.RunEndpointID != query.RunEndpointID {
|
||||
return validationError("runEndpointId must match artifact transfer")
|
||||
}
|
||||
if session.ArtifactID != query.ArtifactID {
|
||||
return validationError("artifactId must match artifact transfer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifactCompleteMatchesSession(complete domain.ArtifactTransferComplete, session domain.ArtifactTransferSession) error {
|
||||
if session.RunEndpointID != complete.RunEndpointID {
|
||||
return validationError("runEndpointId must match artifact transfer")
|
||||
}
|
||||
if session.ArtifactID != complete.ArtifactID {
|
||||
return validationError("artifactId must match artifact transfer")
|
||||
}
|
||||
if session.SizeBytes != complete.SizeBytes {
|
||||
return validationError("sizeBytes must match artifact transfer")
|
||||
}
|
||||
if session.Checksum != complete.Checksum {
|
||||
return validationError("checksum must match artifact transfer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifactTransferOpenResult(session domain.ArtifactTransferSession, artifact domain.Artifact, duplicate bool, stamp time.Time) domain.ArtifactTransferOpenResult {
|
||||
return domain.ArtifactTransferOpenResult{
|
||||
Accepted: true,
|
||||
TransferID: session.TransferID,
|
||||
Direction: session.Direction,
|
||||
Artifact: artifact,
|
||||
TotalChunks: session.TotalChunks,
|
||||
ChunkSizeBytes: session.ChunkSizeBytes,
|
||||
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
|
||||
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
|
||||
Completed: session.Completed,
|
||||
Duplicate: duplicate,
|
||||
ServerTime: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
func artifactChunkUploadResult(session domain.ArtifactTransferSession, chunkIndex int, duplicate bool, stamp time.Time) domain.ArtifactChunkUploadResult {
|
||||
return domain.ArtifactChunkUploadResult{
|
||||
Accepted: true,
|
||||
TransferID: session.TransferID,
|
||||
ArtifactID: session.ArtifactID,
|
||||
ChunkIndex: chunkIndex,
|
||||
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
|
||||
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
|
||||
Duplicate: duplicate,
|
||||
ServerTime: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
func artifactTransferStatusResult(session domain.ArtifactTransferSession, stamp time.Time) domain.ArtifactTransferStatusResult {
|
||||
return domain.ArtifactTransferStatusResult{
|
||||
Accepted: true,
|
||||
TransferID: session.TransferID,
|
||||
ArtifactID: session.ArtifactID,
|
||||
Direction: session.Direction,
|
||||
TotalChunks: session.TotalChunks,
|
||||
ChunkSizeBytes: session.ChunkSizeBytes,
|
||||
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
|
||||
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
|
||||
Completed: session.Completed,
|
||||
ServerTime: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
func artifactTotalChunks(sizeBytes int64, chunkSizeBytes int) int {
|
||||
return int((sizeBytes + int64(chunkSizeBytes) - 1) / int64(chunkSizeBytes))
|
||||
}
|
||||
|
||||
func expectedChunkSize(session domain.ArtifactTransferSession, chunkIndex int) int {
|
||||
offset := int64(chunkIndex) * int64(session.ChunkSizeBytes)
|
||||
remaining := session.SizeBytes - offset
|
||||
if remaining < int64(session.ChunkSizeBytes) {
|
||||
return int(remaining)
|
||||
}
|
||||
return session.ChunkSizeBytes
|
||||
}
|
||||
|
||||
func receivedArtifactChunkIndexes(session domain.ArtifactTransferSession) []int {
|
||||
indexes := make([]int, 0, len(session.ReceivedChunks))
|
||||
for index := range session.ReceivedChunks {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
return indexes
|
||||
}
|
||||
|
||||
func nextMissingArtifactChunkIndex(session domain.ArtifactTransferSession) int {
|
||||
for index := 0; index < session.TotalChunks; index++ {
|
||||
if _, exists := session.ReceivedChunks[index]; !exists {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return session.TotalChunks
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func TestCoreServiceArtifactTransferWorkflow(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredArtifactTransferService(t)
|
||||
payload := []byte("artifact payload for upload")
|
||||
openRequest := validArtifactTransferOpen(sessionToken, payload, 8)
|
||||
|
||||
opened, err := svc.OpenArtifactTransfer(openRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("open artifact transfer: %v", err)
|
||||
}
|
||||
if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.NextMissingChunkIndex != 0 {
|
||||
t.Fatalf("unexpected open response: %+v", opened)
|
||||
}
|
||||
if opened.Artifact.State != domain.ArtifactStateUploading {
|
||||
t.Fatalf("expected uploading artifact, got %+v", opened.Artifact)
|
||||
}
|
||||
|
||||
duplicateOpen, err := svc.OpenArtifactTransfer(openRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate open artifact transfer: %v", err)
|
||||
}
|
||||
if !duplicateOpen.Duplicate || duplicateOpen.TransferID != opened.TransferID {
|
||||
t.Fatalf("expected duplicate open response, got %+v", duplicateOpen)
|
||||
}
|
||||
|
||||
firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
|
||||
firstAck, err := svc.UploadArtifactChunk(firstChunk)
|
||||
if err != nil {
|
||||
t.Fatalf("upload first chunk: %v", err)
|
||||
}
|
||||
if !firstAck.Accepted || firstAck.NextMissingChunkIndex != 1 || len(firstAck.ReceivedChunkIndexes) != 1 || firstAck.ReceivedChunkIndexes[0] != 0 {
|
||||
t.Fatalf("unexpected first chunk ack: %+v", firstAck)
|
||||
}
|
||||
|
||||
duplicateAck, err := svc.UploadArtifactChunk(firstChunk)
|
||||
if err != nil {
|
||||
t.Fatalf("upload duplicate chunk: %v", err)
|
||||
}
|
||||
if !duplicateAck.Duplicate {
|
||||
t.Fatalf("expected duplicate chunk ack, got %+v", duplicateAck)
|
||||
}
|
||||
|
||||
status, err := svc.QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("query artifact transfer status: %v", err)
|
||||
}
|
||||
if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
|
||||
t.Fatalf("unexpected status after first chunk: %+v", status)
|
||||
}
|
||||
|
||||
for index := 1; index < opened.TotalChunks; index++ {
|
||||
if _, err := svc.UploadArtifactChunk(validArtifactChunk(sessionToken, opened.TransferID, payload, index, 8)); err != nil {
|
||||
t.Fatalf("upload chunk %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
completed, err := svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
|
||||
if err != nil {
|
||||
t.Fatalf("complete artifact transfer: %v", err)
|
||||
}
|
||||
if !completed.Accepted || !completed.Completed || completed.Artifact.State != domain.ArtifactStateAvailable {
|
||||
t.Fatalf("unexpected complete response: %+v", completed)
|
||||
}
|
||||
artifact, err := svc.GetArtifact("artifact-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get completed artifact: %v", err)
|
||||
}
|
||||
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) {
|
||||
t.Fatalf("expected available artifact, got %+v", artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsInvalidArtifactTransferChunks(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredArtifactTransferService(t)
|
||||
payload := []byte("artifact payload")
|
||||
opened, err := svc.OpenArtifactTransfer(validArtifactTransferOpen(sessionToken, payload, 8))
|
||||
if err != nil {
|
||||
t.Fatalf("open artifact transfer: %v", err)
|
||||
}
|
||||
|
||||
badChecksum := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
|
||||
badChecksum.Checksum = validator.BytesChecksum([]byte("different"))
|
||||
_, err = svc.UploadArtifactChunk(badChecksum)
|
||||
if err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||
t.Fatalf("expected checksum rejection, got %v", err)
|
||||
}
|
||||
|
||||
firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
|
||||
if _, err := svc.UploadArtifactChunk(firstChunk); err != nil {
|
||||
t.Fatalf("upload first chunk: %v", err)
|
||||
}
|
||||
conflict := firstChunk
|
||||
conflict.Payload = []byte("ARTIFACT")
|
||||
conflict.Checksum = validator.BytesChecksum(conflict.Payload)
|
||||
_, err = svc.UploadArtifactChunk(conflict)
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||
t.Fatalf("expected conflicting chunk rejection, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing chunks") {
|
||||
t.Fatalf("expected missing chunk completion rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsInvalidArtifactTransferOwnerAndSession(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredArtifactTransferService(t)
|
||||
payload := []byte("artifact payload")
|
||||
invalidSession := validArtifactTransferOpen("stale-token", payload, 8)
|
||||
_, err := svc.OpenArtifactTransfer(invalidSession)
|
||||
if err == nil || !strings.Contains(err.Error(), "sessionToken") {
|
||||
t.Fatalf("expected invalid session rejection, got %v", err)
|
||||
}
|
||||
|
||||
otherHello := validRunControlHello()
|
||||
otherHello.RunEndpointID = "run-other"
|
||||
otherHello.DisplayName = "Other Run"
|
||||
otherHello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.start", "logs.read"}
|
||||
otherHello.CapabilityReport.Fingerprint = "cap-other"
|
||||
other, err := svc.RegisterRunHello(otherHello)
|
||||
if err != nil {
|
||||
t.Fatalf("register other run: %v", err)
|
||||
}
|
||||
invalidOwner := validArtifactTransferOpen(other.SessionToken, payload, 8)
|
||||
invalidOwner.RunEndpointID = "run-other"
|
||||
invalidOwner.ArtifactID = "artifact-other"
|
||||
invalidOwner.IdempotencyKey = "artifact-upload-other"
|
||||
_, err = svc.OpenArtifactTransfer(invalidOwner)
|
||||
if err == nil || !strings.Contains(err.Error(), "owner job") {
|
||||
t.Fatalf("expected owner mismatch rejection, got %v", err)
|
||||
}
|
||||
|
||||
validOwner := validArtifactTransferOpen(sessionToken, payload, 8)
|
||||
validOwner.OwnerKind = domain.ArtifactOwnerKindPlatform
|
||||
_, err = svc.OpenArtifactTransfer(validOwner)
|
||||
if err == nil || !strings.Contains(err.Error(), "ownerKind") {
|
||||
t.Fatalf("expected owner kind rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredArtifactTransferService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-1",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "SCUM #1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateJob(domain.Job{ID: "job-1", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-start"}); err != nil {
|
||||
t.Fatalf("create job: %v", err)
|
||||
}
|
||||
hello, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register run hello: %v", err)
|
||||
}
|
||||
return svc, hello.SessionToken
|
||||
}
|
||||
|
||||
func validArtifactTransferOpen(sessionToken string, payload []byte, chunkSize int) domain.ArtifactTransferOpen {
|
||||
return domain.ArtifactTransferOpen{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
ArtifactID: "artifact-1",
|
||||
Direction: domain.ArtifactTransferDirectionUpload,
|
||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||
OwnerID: "job-1",
|
||||
SizeBytes: int64(len(payload)),
|
||||
ChunkSizeBytes: chunkSize,
|
||||
Checksum: validator.BytesChecksum(payload),
|
||||
IdempotencyKey: "artifact-upload-1",
|
||||
}
|
||||
}
|
||||
|
||||
func validArtifactChunk(sessionToken string, transferID string, payload []byte, index int, chunkSize int) domain.ArtifactChunkUpload {
|
||||
offset := index * chunkSize
|
||||
end := offset + chunkSize
|
||||
if end > len(payload) {
|
||||
end = len(payload)
|
||||
}
|
||||
chunkPayload := payload[offset:end]
|
||||
return domain.ArtifactChunkUpload{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
TransferID: transferID,
|
||||
ArtifactID: "artifact-1",
|
||||
ChunkIndex: index,
|
||||
Offset: int64(offset),
|
||||
SizeBytes: len(chunkPayload),
|
||||
Checksum: validator.BytesChecksum(chunkPayload),
|
||||
Payload: chunkPayload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHeartbeatIntervalSeconds = 15
|
||||
)
|
||||
|
||||
func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.RunControlHelloResult, error) {
|
||||
hello = domain.CopyRunControlHello(hello)
|
||||
if err := validator.ValidateRunControlHello(hello); err != nil {
|
||||
return domain.RunControlHelloResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
endpoint := domain.RunEndpoint{
|
||||
ID: hello.RunEndpointID,
|
||||
DisplayName: hello.DisplayName,
|
||||
Version: hello.Version,
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities),
|
||||
Capacity: hello.Capacity,
|
||||
LastHeartbeatAt: stamp,
|
||||
}
|
||||
if err := validator.ValidateRunEndpoint(endpoint); err != nil {
|
||||
return domain.RunControlHelloResult{}, err
|
||||
}
|
||||
|
||||
svc.controlMu.Lock()
|
||||
defer svc.controlMu.Unlock()
|
||||
|
||||
if err := svc.upsertRunEndpoint(endpoint); err != nil {
|
||||
return domain.RunControlHelloResult{}, err
|
||||
}
|
||||
sessionToken := svc.nextSessionToken(hello.RunEndpointID, stamp)
|
||||
svc.runSessions[hello.RunEndpointID] = domain.RunControlSession{
|
||||
RunEndpointID: hello.RunEndpointID,
|
||||
SessionToken: sessionToken,
|
||||
CapabilityFingerprint: hello.CapabilityReport.Fingerprint,
|
||||
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
|
||||
return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: hello.RunEndpointID,
|
||||
SessionToken: sessionToken,
|
||||
ServerTime: stamp,
|
||||
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
|
||||
FeatureFlags: []string{"control.hello", "control.heartbeat"},
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) {
|
||||
heartbeat = domain.CopyRunControlHeartbeat(heartbeat)
|
||||
if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil {
|
||||
return domain.RunControlHeartbeatResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
|
||||
svc.controlMu.Lock()
|
||||
defer svc.controlMu.Unlock()
|
||||
|
||||
session, exists := svc.runSessions[heartbeat.RunEndpointID]
|
||||
if !exists || session.SessionToken != heartbeat.SessionToken {
|
||||
return domain.RunControlHeartbeatResult{}, validationError("sessionToken is invalid")
|
||||
}
|
||||
|
||||
endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.RunControlHeartbeatResult{}, err
|
||||
}
|
||||
endpoint.Version = heartbeat.Version
|
||||
endpoint.Status = heartbeat.Status
|
||||
endpoint.Capacity = heartbeat.Capacity
|
||||
endpoint.LastHeartbeatAt = stamp
|
||||
if err := validator.ValidateRunEndpoint(endpoint); err != nil {
|
||||
return domain.RunControlHeartbeatResult{}, err
|
||||
}
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
return domain.RunControlHeartbeatResult{}, err
|
||||
}
|
||||
|
||||
refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint
|
||||
session.CapabilityFingerprint = heartbeat.CapabilityFingerprint
|
||||
session.UpdatedAt = stamp
|
||||
svc.runSessions[heartbeat.RunEndpointID] = session
|
||||
|
||||
return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: heartbeat.RunEndpointID,
|
||||
NextHeartbeatSeconds: session.HeartbeatIntervalSeconds,
|
||||
RefreshCapabilities: refreshCapabilities,
|
||||
ServerTime: stamp,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error {
|
||||
if _, err := svc.store.RunEndpoints().Get(endpoint.ID); err != nil {
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
return svc.store.RunEndpoints().Create(endpoint)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return svc.store.RunEndpoints().Update(endpoint)
|
||||
}
|
||||
|
||||
func (svc *CoreService) nextSessionToken(runEndpointID string, stamp time.Time) string {
|
||||
svc.runSessionSeq++
|
||||
return fmt.Sprintf("session:%s:%d:%d", runEndpointID, stamp.UnixNano(), svc.runSessionSeq)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
|
||||
result, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register hello: %v", err)
|
||||
}
|
||||
if !result.Accepted || result.SessionToken == "" || result.HeartbeatIntervalSeconds <= 0 {
|
||||
t.Fatalf("expected accepted hello response, got %+v", result)
|
||||
}
|
||||
|
||||
endpoint, err := svc.GetRunEndpoint("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get registered endpoint: %v", err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("expected capabilities and capacity, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceReRegistersExistingRunEndpoint(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
first, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register first hello: %v", err)
|
||||
}
|
||||
|
||||
hello := validRunControlHello()
|
||||
hello.DisplayName = "Local Run Updated"
|
||||
hello.Version = "0.2.0"
|
||||
hello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "jobs.claim"}
|
||||
hello.CapabilityReport.Fingerprint = "cap-v2"
|
||||
second, err := svc.RegisterRunHello(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("register second hello: %v", err)
|
||||
}
|
||||
if second.SessionToken == first.SessionToken {
|
||||
t.Fatalf("expected re-registration to issue a new token, got %q", second.SessionToken)
|
||||
}
|
||||
|
||||
endpoint, err := svc.GetRunEndpoint("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get re-registered endpoint: %v", err)
|
||||
}
|
||||
if endpoint.DisplayName != "Local Run Updated" || endpoint.Version != "0.2.0" || len(endpoint.Capabilities) != 3 {
|
||||
t.Fatalf("expected endpoint metadata update, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceAcceptsRunHeartbeat(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
hello, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register hello: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Version: "0.1.1",
|
||||
Status: domain.RunEndpointStatusDegraded,
|
||||
CapabilityFingerprint: "cap-v1",
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 2, QueuedJobs: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accept heartbeat: %v", err)
|
||||
}
|
||||
if !result.Accepted || result.RefreshCapabilities {
|
||||
t.Fatalf("expected accepted heartbeat without refresh, got %+v", result)
|
||||
}
|
||||
|
||||
endpoint, err := svc.GetRunEndpoint("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get heartbeat endpoint: %v", err)
|
||||
}
|
||||
if endpoint.Status != domain.RunEndpointStatusDegraded || endpoint.Version != "0.1.1" || endpoint.Capacity.RunningJobs != 2 {
|
||||
t.Fatalf("expected heartbeat metadata update, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsInvalidRunHeartbeatToken(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil {
|
||||
t.Fatalf("register hello: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: "stale-token",
|
||||
Version: "0.1.1",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
CapabilityFingerprint: "cap-v1",
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 3},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "sessionToken is invalid") {
|
||||
t.Fatalf("expected invalid token rejection, got %v", err)
|
||||
}
|
||||
|
||||
endpoint, err := svc.GetRunEndpoint("run-local")
|
||||
if err != nil {
|
||||
t.Fatalf("get endpoint after rejected heartbeat: %v", err)
|
||||
}
|
||||
if endpoint.Capacity.RunningJobs != 0 || endpoint.Version != "0.1.0" {
|
||||
t.Fatalf("heartbeat with invalid token must not update endpoint, got %+v", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsInvalidRunControlHello(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
invalid := validRunControlHello()
|
||||
invalid.RegistrationToken = ""
|
||||
invalid.Capacity.RunningJobs = 8
|
||||
|
||||
_, err := svc.RegisterRunHello(invalid)
|
||||
if err == nil || !strings.Contains(err.Error(), "registrationToken") || !strings.Contains(err.Error(), "runningJobs") {
|
||||
t.Fatalf("expected validation errors, got %v", err)
|
||||
}
|
||||
if _, err := svc.GetRunEndpoint("run-local"); !errors.Is(err, repo.ErrNotFound) {
|
||||
t.Fatalf("invalid hello must not create endpoint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
hello, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register hello: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Version: "0.1.0",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
CapabilityFingerprint: "cap-v2",
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accept drift heartbeat: %v", err)
|
||||
}
|
||||
if !result.RefreshCapabilities {
|
||||
t.Fatalf("expected capability refresh request, got %+v", result)
|
||||
}
|
||||
|
||||
result, err = svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Version: "0.1.0",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
CapabilityFingerprint: "cap-v2",
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("accept stable heartbeat: %v", err)
|
||||
}
|
||||
if result.RefreshCapabilities {
|
||||
t.Fatalf("expected refreshed fingerprint to become known, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func validRunControlHello() domain.RunControlHello {
|
||||
return domain.RunControlHello{
|
||||
RegistrationToken: "registration-token",
|
||||
RunEndpointID: "run-local",
|
||||
DisplayName: "Local Run",
|
||||
Version: "0.1.0",
|
||||
Status: domain.RunEndpointStatusOnline,
|
||||
Platform: "darwin/arm64",
|
||||
CapabilityReport: domain.RunCapabilityReport{
|
||||
Capabilities: []string{"control.hello", "control.heartbeat"},
|
||||
Fingerprint: "cap-v1",
|
||||
},
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const defaultJobPollSeconds = 2
|
||||
|
||||
func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClaimResult, error) {
|
||||
claim = domain.CopyRunJobClaim(claim)
|
||||
if err := validator.ValidateRunJobClaim(claim); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(claim.RunEndpointID, claim.SessionToken); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID, State: domain.JobStateQueued})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
job, ok := firstSupportedJob(jobs, claim.Capabilities)
|
||||
if !ok {
|
||||
return domain.RunJobClaimResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: claim.RunEndpointID,
|
||||
NextPollSeconds: defaultJobPollSeconds,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
lease := svc.newJobLease(job.ID, claim.RunEndpointID, claim.SessionToken, stamp)
|
||||
svc.jobLeases[job.ID] = lease
|
||||
job.State = domain.JobStateAccepted
|
||||
job.UpdatedAt = stamp
|
||||
if err := validator.ValidateJob(job); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
}
|
||||
assignment := assignmentFromJob(job, lease)
|
||||
return domain.CopyRunJobClaimResult(domain.RunJobClaimResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: claim.RunEndpointID,
|
||||
HasJob: true,
|
||||
Job: &assignment,
|
||||
NextPollSeconds: defaultJobPollSeconds,
|
||||
ServerTime: stamp,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) {
|
||||
if err := validator.ValidateRunJobAck(ack); err != nil {
|
||||
return domain.RunJobAckResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(ack.RunEndpointID, ack.SessionToken); err != nil {
|
||||
return domain.RunJobAckResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
job, lease, err := svc.activeLeasedJob(ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt)
|
||||
if err != nil {
|
||||
return domain.RunJobAckResult{}, err
|
||||
}
|
||||
if isTerminalJobState(job.State) {
|
||||
return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
|
||||
}
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
return domain.RunJobAckResult{}, validationError("job is not claimable for ack")
|
||||
}
|
||||
job.State = domain.JobStateRunning
|
||||
if strings.TrimSpace(ack.Message) != "" {
|
||||
job.Progress.Message = ack.Message
|
||||
}
|
||||
job.UpdatedAt = stamp
|
||||
if err := validator.ValidateJob(job); err != nil {
|
||||
return domain.RunJobAckResult{}, err
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
return domain.RunJobAckResult{}, err
|
||||
}
|
||||
lease.UpdatedAt = stamp
|
||||
svc.jobLeases[job.ID] = lease
|
||||
return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (domain.RunJobProgressResult, error) {
|
||||
if err := validator.ValidateRunJobProgress(progress); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(progress.RunEndpointID, progress.SessionToken); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
job, lease, err := svc.activeLeasedJob(progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt)
|
||||
if err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
return domain.RunJobProgressResult{}, validationError("job is not active")
|
||||
}
|
||||
job.State = domain.JobStateRunning
|
||||
job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message}
|
||||
job.UpdatedAt = stamp
|
||||
if err := validator.ValidateJob(job); err != nil {
|
||||
return domain.RunJobProgressResult{}, err
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); 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
|
||||
}
|
||||
|
||||
func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJobResultResult, error) {
|
||||
if err := validator.ValidateRunJobResult(result); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(result.RunEndpointID, result.SessionToken); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
job, lease, err := svc.activeLeasedJob(result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt)
|
||||
if err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
fingerprint := terminalFingerprint(result)
|
||||
if isTerminalJobState(job.State) {
|
||||
if lease.TerminalFingerprint != "" && lease.TerminalFingerprint == fingerprint {
|
||||
if err := svc.projectLifecycleJobResult(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")
|
||||
}
|
||||
|
||||
job.State = result.State
|
||||
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)}
|
||||
job.ResultRef = result.ResultRef
|
||||
job.UpdatedAt = stamp
|
||||
if err := validator.ValidateJob(job); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.store.Jobs().Update(job); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
|
||||
return domain.RunJobResultResult{}, err
|
||||
}
|
||||
lease.TerminalFingerprint = fingerprint
|
||||
lease.UpdatedAt = stamp
|
||||
svc.jobLeases[job.ID] = lease
|
||||
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) RequestRunJobCancel(request domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) {
|
||||
if err := validator.ValidateRunJobCancelRequest(request); err != nil {
|
||||
return domain.RunJobCancelRequestResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
job, err := svc.store.Jobs().Get(request.JobID)
|
||||
if err != nil {
|
||||
return domain.RunJobCancelRequestResult{}, err
|
||||
}
|
||||
if !isActiveJobState(job.State) {
|
||||
return domain.RunJobCancelRequestResult{}, validationError("job is not active")
|
||||
}
|
||||
lease, exists := svc.jobLeases[job.ID]
|
||||
if !exists {
|
||||
return domain.RunJobCancelRequestResult{}, validationError("job lease is missing")
|
||||
}
|
||||
lease.CancelReason = request.Reason
|
||||
lease.CancelRequestedAt = stamp
|
||||
lease.UpdatedAt = stamp
|
||||
svc.jobLeases[job.ID] = lease
|
||||
return domain.RunJobCancelRequestResult{Accepted: true, JobID: job.ID, Reason: request.Reason, RequestedAt: stamp}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) PollRunJobCancel(poll domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) {
|
||||
if err := validator.ValidateRunJobCancelPoll(poll); err != nil {
|
||||
return domain.RunJobCancelPollResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(poll.RunEndpointID, poll.SessionToken); err != nil {
|
||||
return domain.RunJobCancelPollResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
lease, ok := svc.findCancelLease(poll)
|
||||
if !ok {
|
||||
return domain.RunJobCancelPollResult{Accepted: true, RunEndpointID: poll.RunEndpointID, ServerTime: stamp}, nil
|
||||
}
|
||||
return domain.RunJobCancelPollResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: poll.RunEndpointID,
|
||||
HasCancel: true,
|
||||
JobID: lease.JobID,
|
||||
Reason: lease.CancelReason,
|
||||
RequestedAt: lease.CancelRequestedAt,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReconcileRunJobs(reconcile domain.RunJobReconcile) (domain.RunJobReconcileResult, error) {
|
||||
reconcile = domain.CopyRunJobReconcile(reconcile)
|
||||
if err := validator.ValidateRunJobReconcile(reconcile); err != nil {
|
||||
return domain.RunJobReconcileResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(reconcile.RunEndpointID, reconcile.SessionToken); err != nil {
|
||||
return domain.RunJobReconcileResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
svc.jobMu.Lock()
|
||||
defer svc.jobMu.Unlock()
|
||||
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: reconcile.RunEndpointID})
|
||||
if err != nil {
|
||||
return domain.RunJobReconcileResult{}, err
|
||||
}
|
||||
activeByID := map[string]domain.Job{}
|
||||
for _, job := range jobs {
|
||||
if isActiveJobState(job.State) {
|
||||
activeByID[job.ID] = job
|
||||
}
|
||||
}
|
||||
|
||||
activeJobs := make([]domain.RunJobAssignment, 0, len(activeByID))
|
||||
ids := make([]string, 0, len(activeByID))
|
||||
for id := range activeByID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
job := activeByID[id]
|
||||
lease := svc.jobLeases[job.ID]
|
||||
if lease.JobID == "" || lease.SessionToken != reconcile.SessionToken {
|
||||
lease = svc.newJobLease(job.ID, reconcile.RunEndpointID, reconcile.SessionToken, stamp)
|
||||
} else {
|
||||
lease.UpdatedAt = stamp
|
||||
}
|
||||
svc.jobLeases[job.ID] = lease
|
||||
activeJobs = append(activeJobs, assignmentFromJob(job, lease))
|
||||
}
|
||||
|
||||
unknown := make([]string, 0)
|
||||
for _, reportedID := range reconcile.ActiveJobIDs {
|
||||
if _, exists := activeByID[reportedID]; !exists {
|
||||
unknown = append(unknown, reportedID)
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return domain.CopyRunJobReconcileResult(domain.RunJobReconcileResult{
|
||||
Accepted: true,
|
||||
RunEndpointID: reconcile.RunEndpointID,
|
||||
ActiveJobs: activeJobs,
|
||||
UnknownJobIDs: unknown,
|
||||
ServerTime: stamp,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) validateRunSession(runEndpointID string, sessionToken string) error {
|
||||
svc.controlMu.Lock()
|
||||
defer svc.controlMu.Unlock()
|
||||
session, exists := svc.runSessions[runEndpointID]
|
||||
if !exists || session.SessionToken != sessionToken {
|
||||
return validationError("sessionToken is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) newJobLease(jobID string, runEndpointID string, sessionToken string, stamp time.Time) domain.RunJobLease {
|
||||
svc.jobLeaseSeq++
|
||||
return domain.RunJobLease{
|
||||
JobID: jobID,
|
||||
RunEndpointID: runEndpointID,
|
||||
SessionToken: sessionToken,
|
||||
LeaseToken: fmt.Sprintf("job-lease:%s:%d:%d", jobID, stamp.UnixNano(), svc.jobLeaseSeq),
|
||||
Attempt: int(svc.jobLeaseSeq),
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *CoreService) activeLeasedJob(runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) (domain.Job, domain.RunJobLease, error) {
|
||||
job, err := svc.store.Jobs().Get(jobID)
|
||||
if err != nil {
|
||||
return domain.Job{}, domain.RunJobLease{}, err
|
||||
}
|
||||
if job.RunEndpointID != runEndpointID {
|
||||
return domain.Job{}, domain.RunJobLease{}, validationError("job runEndpointId does not match request")
|
||||
}
|
||||
lease, exists := svc.jobLeases[jobID]
|
||||
if !exists || lease.SessionToken != sessionToken || lease.LeaseToken != leaseToken || lease.Attempt != attempt {
|
||||
return domain.Job{}, domain.RunJobLease{}, validationError("leaseToken is invalid")
|
||||
}
|
||||
return job, lease, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) findCancelLease(poll domain.RunJobCancelPoll) (domain.RunJobLease, bool) {
|
||||
if poll.JobID != "" {
|
||||
lease, exists := svc.jobLeases[poll.JobID]
|
||||
if !exists || lease.RunEndpointID != poll.RunEndpointID || lease.SessionToken != poll.SessionToken {
|
||||
return domain.RunJobLease{}, false
|
||||
}
|
||||
if poll.LeaseToken != "" && lease.LeaseToken != poll.LeaseToken {
|
||||
return domain.RunJobLease{}, false
|
||||
}
|
||||
return lease, lease.CancelReason != ""
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(svc.jobLeases))
|
||||
for id := range svc.jobLeases {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
lease := svc.jobLeases[id]
|
||||
if lease.RunEndpointID == poll.RunEndpointID && lease.SessionToken == poll.SessionToken && lease.CancelReason != "" {
|
||||
return lease, true
|
||||
}
|
||||
}
|
||||
return domain.RunJobLease{}, false
|
||||
}
|
||||
|
||||
func firstSupportedJob(jobs []domain.Job, capabilities []string) (domain.Job, bool) {
|
||||
capabilitySet := map[string]struct{}{}
|
||||
for _, capability := range capabilities {
|
||||
capabilitySet[capability] = struct{}{}
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if len(capabilitySet) == 0 {
|
||||
return job, true
|
||||
}
|
||||
if _, supported := capabilitySet[job.Capability]; supported {
|
||||
return job, true
|
||||
}
|
||||
}
|
||||
return domain.Job{}, false
|
||||
}
|
||||
|
||||
func assignmentFromJob(job domain.Job, lease domain.RunJobLease) domain.RunJobAssignment {
|
||||
return domain.RunJobAssignment{
|
||||
JobID: job.ID,
|
||||
ServerInstanceID: job.ServerInstanceID,
|
||||
RunEndpointID: job.RunEndpointID,
|
||||
Capability: job.Capability,
|
||||
TargetKey: job.TargetKey,
|
||||
InputRef: job.InputRef,
|
||||
IdempotencyKey: job.IdempotencyKey,
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
LeaseToken: lease.LeaseToken,
|
||||
Attempt: lease.Attempt,
|
||||
CreatedAt: job.CreatedAt,
|
||||
UpdatedAt: job.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func terminalFingerprint(result domain.RunJobResult) string {
|
||||
return fmt.Sprintf("%s|%d|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message)
|
||||
}
|
||||
|
||||
func terminalMessage(result domain.RunJobResult) string {
|
||||
if strings.TrimSpace(result.Message) != "" {
|
||||
return result.Message
|
||||
}
|
||||
return result.Progress.Message
|
||||
}
|
||||
|
||||
func isActiveJobState(state domain.JobState) bool {
|
||||
return state == domain.JobStateAccepted || state == domain.JobStateRunning
|
||||
}
|
||||
|
||||
func isTerminalJobState(state domain.JobState) bool {
|
||||
return state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestCoreServiceRunJobLifecycle(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
Capabilities: []string{"process.start"},
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
if !claim.Accepted || !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted {
|
||||
t.Fatalf("expected claimed job, got %+v", claim)
|
||||
}
|
||||
|
||||
ack, err := svc.AckRunJob(domain.RunJobAck{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
Message: "starting",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ack job: %v", err)
|
||||
}
|
||||
if ack.Job.State != domain.JobStateRunning || ack.Job.Progress.Message != "starting" {
|
||||
t.Fatalf("expected running ack job, got %+v", ack)
|
||||
}
|
||||
|
||||
progress, err := svc.UpdateRunJobProgress(domain.RunJobProgress{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
Progress: domain.RunJobProgressReport{Percent: 50, Message: "half"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("progress job: %v", err)
|
||||
}
|
||||
if progress.Job.Progress.Percent != 50 || progress.Job.Progress.Message != "half" {
|
||||
t.Fatalf("expected progress update, got %+v", progress)
|
||||
}
|
||||
|
||||
result, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"},
|
||||
ResultRef: "artifact://jobs/job-1/result",
|
||||
Message: "done",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete job: %v", err)
|
||||
}
|
||||
if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef != "artifact://jobs/job-1/result" {
|
||||
t.Fatalf("expected succeeded result, got %+v", result)
|
||||
}
|
||||
|
||||
stored, err := svc.GetJob("job-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get completed job: %v", err)
|
||||
}
|
||||
if stored.State != domain.JobStateSucceeded || stored.Progress.Percent != 100 {
|
||||
t.Fatalf("expected stored terminal job, got %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobClaimNoJob(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
Capabilities: []string{"process.start"},
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim no job: %v", err)
|
||||
}
|
||||
if !claim.Accepted || claim.HasJob || claim.Job != nil || claim.NextPollSeconds <= 0 {
|
||||
t.Fatalf("expected empty claim response, got %+v", claim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
|
||||
_, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: "stale", Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err == nil || !strings.Contains(err.Error(), "sessionToken") {
|
||||
t.Fatalf("expected invalid session rejection, got %v", err)
|
||||
}
|
||||
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
_, err = svc.UpdateRunJobProgress(domain.RunJobProgress{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: "bad-lease",
|
||||
Attempt: claim.Job.Attempt,
|
||||
Progress: domain.RunJobProgressReport{Percent: 10},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "leaseToken") {
|
||||
t.Fatalf("expected invalid lease rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobRejectsInvalidProgress(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.UpdateRunJobProgress(domain.RunJobProgress{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
Progress: domain.RunJobProgressReport{Percent: 101},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "progress.percent") {
|
||||
t.Fatalf("expected invalid progress rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobCancelPoll(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil {
|
||||
t.Fatalf("ack job: %v", err)
|
||||
}
|
||||
|
||||
cancel, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: "job-1", Reason: "operator requested"})
|
||||
if err != nil {
|
||||
t.Fatalf("request cancel: %v", err)
|
||||
}
|
||||
if !cancel.Accepted || cancel.Reason != "operator requested" {
|
||||
t.Fatalf("unexpected cancel request: %+v", cancel)
|
||||
}
|
||||
|
||||
poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: "job-1", LeaseToken: claim.Job.LeaseToken})
|
||||
if err != nil {
|
||||
t.Fatalf("poll cancel: %v", err)
|
||||
}
|
||||
if !poll.HasCancel || poll.JobID != "job-1" || poll.Reason != "operator requested" {
|
||||
t.Fatalf("expected cancel poll result, got %+v", poll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
|
||||
request := domain.RunJobResult{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"},
|
||||
ResultRef: "artifact://jobs/job-1/result",
|
||||
Message: "done",
|
||||
}
|
||||
first, err := svc.CompleteRunJob(request)
|
||||
if err != nil {
|
||||
t.Fatalf("complete first: %v", err)
|
||||
}
|
||||
second, err := svc.CompleteRunJob(request)
|
||||
if err != nil {
|
||||
t.Fatalf("complete duplicate: %v", err)
|
||||
}
|
||||
if second.Job.State != first.Job.State || second.Job.ResultRef != first.Job.ResultRef {
|
||||
t.Fatalf("expected duplicate result to be idempotent, got %+v %+v", first, second)
|
||||
}
|
||||
|
||||
request.State = domain.JobStateFailed
|
||||
request.Message = "failed"
|
||||
_, err = svc.CompleteRunJob(request)
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||
t.Fatalf("expected conflicting result rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobReconcile(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil {
|
||||
t.Fatalf("claim job: %v", err)
|
||||
}
|
||||
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil {
|
||||
t.Fatalf("ack job: %v", err)
|
||||
}
|
||||
|
||||
reconcile, err := svc.ReconcileRunJobs(domain.RunJobReconcile{RunEndpointID: "run-local", SessionToken: sessionToken, ActiveJobIDs: []string{"job-1", "local-only"}})
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile jobs: %v", err)
|
||||
}
|
||||
if len(reconcile.ActiveJobs) != 1 || reconcile.ActiveJobs[0].JobID != "job-1" {
|
||||
t.Fatalf("expected platform active job, got %+v", reconcile)
|
||||
}
|
||||
if len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" {
|
||||
t.Fatalf("expected unknown local job, got %+v", reconcile.UnknownJobIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start")
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register run hello: %v", err)
|
||||
}
|
||||
return svc, hello.SessionToken
|
||||
}
|
||||
|
||||
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
|
||||
t.Helper()
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: id,
|
||||
RunEndpointID: "run-local",
|
||||
Capability: "process.start",
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create queued job: %v", err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
type LogBodyStore interface {
|
||||
AppendBatch(streamID string, record domain.LogBatchRecord) error
|
||||
GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error)
|
||||
Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error)
|
||||
}
|
||||
|
||||
type MemoryLogBodyStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string][]domain.LogEntry
|
||||
batches map[string]map[uint64]domain.LogBatchRecord
|
||||
}
|
||||
|
||||
func NewMemoryLogBodyStore() *MemoryLogBodyStore {
|
||||
return &MemoryLogBodyStore{
|
||||
entries: map[string][]domain.LogEntry{},
|
||||
batches: map[string]map[uint64]domain.LogBatchRecord{},
|
||||
}
|
||||
}
|
||||
|
||||
func (store *MemoryLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
records := store.batches[streamID]
|
||||
if records == nil {
|
||||
records = map[uint64]domain.LogBatchRecord{}
|
||||
store.batches[streamID] = records
|
||||
}
|
||||
if existing, exists := records[record.FirstSeq]; exists {
|
||||
if existing.LastSeq == record.LastSeq && existing.Checksum == record.Checksum {
|
||||
return nil
|
||||
}
|
||||
return validationError("log batch conflicts with acknowledged range")
|
||||
}
|
||||
records[record.FirstSeq] = domain.CopyLogBatchRecord(record)
|
||||
store.entries[streamID] = append(store.entries[streamID], domain.CopyLogEntries(record.Entries)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *MemoryLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
record, exists := store.batches[streamID][firstSeq]
|
||||
if !exists {
|
||||
return domain.LogBatchRecord{}, false, nil
|
||||
}
|
||||
return domain.CopyLogBatchRecord(record), true, nil
|
||||
}
|
||||
|
||||
func (store *MemoryLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
entries := domain.CopyLogEntries(store.entries[streamID])
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
|
||||
selected := make([]domain.LogEntry, 0, limit)
|
||||
nextSeq := afterSeq
|
||||
for _, entry := range entries {
|
||||
if entry.Seq <= afterSeq {
|
||||
continue
|
||||
}
|
||||
if len(selected) >= limit {
|
||||
break
|
||||
}
|
||||
selected = append(selected, entry)
|
||||
nextSeq = entry.Seq
|
||||
}
|
||||
return selected, nextSeq, nil
|
||||
}
|
||||
|
||||
type FileLogBodyStore struct {
|
||||
mu sync.Mutex
|
||||
rootDir string
|
||||
memory *MemoryLogBodyStore
|
||||
}
|
||||
|
||||
func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) {
|
||||
rootDir = strings.TrimSpace(rootDir)
|
||||
if rootDir == "" {
|
||||
return nil, fmt.Errorf("log directory is required")
|
||||
}
|
||||
store := &FileLogBodyStore{
|
||||
rootDir: rootDir,
|
||||
memory: NewMemoryLogBodyStore(),
|
||||
}
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) RootDir() string {
|
||||
return store.rootDir
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if _, exists, err := store.memory.GetBatch(streamID, record.FirstSeq); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return store.memory.AppendBatch(streamID, record)
|
||||
}
|
||||
streamDir := store.streamDir(streamID)
|
||||
if err := os.MkdirAll(streamDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create log stream directory: %w", err)
|
||||
}
|
||||
segmentPath := store.segmentPath(streamID, record.FirstSeq)
|
||||
if _, err := os.Stat(segmentPath); err == nil {
|
||||
return validationError("log batch conflicts with acknowledged range")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat log segment: %w", err)
|
||||
}
|
||||
tmpPath := segmentPath + ".tmp"
|
||||
file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log segment: %w", err)
|
||||
}
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, entry := range record.Entries {
|
||||
if err := encoder.Encode(domain.CopyLogEntry(entry)); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write log segment: %w", err)
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close log segment: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, segmentPath); err != nil {
|
||||
return fmt.Errorf("replace log segment: %w", err)
|
||||
}
|
||||
return store.memory.AppendBatch(streamID, record)
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
|
||||
return store.memory.GetBatch(streamID, firstSeq)
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
|
||||
return store.memory.Query(streamID, afterSeq, limit)
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) load() error {
|
||||
entries, err := os.ReadDir(store.rootDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read log root: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
streamID, err := url.PathUnescape(entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode log stream directory: %w", err)
|
||||
}
|
||||
if err := store.loadStream(streamID, filepath.Join(store.rootDir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) loadStream(streamID string, streamDir string) error {
|
||||
segments, err := os.ReadDir(streamDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read log stream directory: %w", err)
|
||||
}
|
||||
sort.SliceStable(segments, func(i, j int) bool { return segments[i].Name() < segments[j].Name() })
|
||||
for _, segment := range segments {
|
||||
if segment.IsDir() || !strings.HasPrefix(segment.Name(), "segment-") || !strings.HasSuffix(segment.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
segmentPath := filepath.Join(streamDir, segment.Name())
|
||||
record, err := readLogSegment(segmentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(record.Entries) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := store.memory.AppendBatch(streamID, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) streamDir(streamID string) string {
|
||||
return filepath.Join(store.rootDir, url.PathEscape(streamID))
|
||||
}
|
||||
|
||||
func (store *FileLogBodyStore) segmentPath(streamID string, firstSeq uint64) string {
|
||||
return filepath.Join(store.streamDir(streamID), fmt.Sprintf("segment-%020d.jsonl", firstSeq))
|
||||
}
|
||||
|
||||
func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("open log segment: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
entries := []domain.LogEntry{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
var entry domain.LogEntry
|
||||
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
entries = append(entries, domain.CopyLogEntry(entry))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
|
||||
if len(entries) == 0 {
|
||||
return domain.LogBatchRecord{}, nil
|
||||
}
|
||||
checksum, err := validator.LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
return domain.LogBatchRecord{}, fmt.Errorf("checksum log segment %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
return domain.LogBatchRecord{
|
||||
Checksum: checksum,
|
||||
FirstSeq: entries[0].Seq,
|
||||
LastSeq: entries[len(entries)-1].Seq,
|
||||
Entries: entries,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const defaultLogQueryLimit = 100
|
||||
|
||||
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
|
||||
batch = domain.CopyLogBatchIngest(batch)
|
||||
if err := validator.ValidateLogBatchIngest(batch); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||
if err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if err := validateLogBatchStream(batch, stream); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
|
||||
if batch.LastSeq <= stream.LatestSeq {
|
||||
record, exists, err := svc.logStore.GetBatch(batch.LogStreamID, batch.FirstSeq)
|
||||
if err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum {
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
Duplicate: true,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
return domain.LogBatchIngestResult{}, validationError("log batch conflicts with acknowledged range")
|
||||
}
|
||||
if batch.FirstSeq != stream.LatestSeq+1 {
|
||||
return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
|
||||
}
|
||||
|
||||
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
|
||||
Checksum: batch.Checksum,
|
||||
FirstSeq: batch.FirstSeq,
|
||||
LastSeq: batch.LastSeq,
|
||||
Entries: batch.Entries,
|
||||
})
|
||||
if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
stream.LatestSeq = batch.LastSeq
|
||||
stream.UpdatedAt = stamp
|
||||
if err := svc.store.LogStreams().Update(stream); err != nil {
|
||||
return domain.LogBatchIngestResult{}, err
|
||||
}
|
||||
return domain.LogBatchIngestResult{
|
||||
Accepted: true,
|
||||
LogStreamID: batch.LogStreamID,
|
||||
AcceptedFrom: batch.FirstSeq,
|
||||
AcceptedTo: batch.LastSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
ServerTime: stamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
||||
if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
}
|
||||
stream, err := svc.store.LogStreams().Get(query.LogStreamID)
|
||||
if err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit == 0 {
|
||||
limit = defaultLogQueryLimit
|
||||
}
|
||||
|
||||
selected, nextSeq, err := svc.logStore.Query(query.LogStreamID, query.AfterSeq, limit)
|
||||
if err != nil {
|
||||
return domain.LogStreamCursorResult{}, err
|
||||
}
|
||||
return domain.CopyLogStreamCursorResult(domain.LogStreamCursorResult{
|
||||
LogStreamID: query.LogStreamID,
|
||||
Entries: selected,
|
||||
NextSeq: nextSeq,
|
||||
LatestSeq: stream.LatestSeq,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func validateLogBatchStream(batch domain.LogBatchIngest, stream domain.LogStream) error {
|
||||
if stream.ID != batch.LogStreamID {
|
||||
return validationError("logStreamId must match stream")
|
||||
}
|
||||
if stream.ServerInstanceID != batch.ServerInstanceID {
|
||||
return validationError("serverInstanceId must match stream")
|
||||
}
|
||||
if stream.StreamKey != batch.StreamKey {
|
||||
return validationError("streamKey must match stream")
|
||||
}
|
||||
if stream.Source != batch.Source {
|
||||
return validationError("source must match stream")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func TestCoreServiceIngestsLogBatchAndQueriesCursor(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
batch := validLogBatch(t, sessionToken, 1, 2)
|
||||
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest log batch: %v", err)
|
||||
}
|
||||
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 {
|
||||
t.Fatalf("unexpected ack: %+v", ack)
|
||||
}
|
||||
stream, err := svc.GetLogStream("log-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get log stream: %v", err)
|
||||
}
|
||||
if stream.LatestSeq != 2 {
|
||||
t.Fatalf("expected latest seq 2, got %+v", stream)
|
||||
}
|
||||
|
||||
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", AfterSeq: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("query log stream: %v", err)
|
||||
}
|
||||
if len(query.Entries) != 1 || query.Entries[0].Seq != 2 || query.NextSeq != 2 || query.LatestSeq != 2 {
|
||||
t.Fatalf("unexpected query result: %+v", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
batch := validLogBatch(t, sessionToken, 1, 2)
|
||||
|
||||
if _, err := svc.IngestLogBatch(batch); err != nil {
|
||||
t.Fatalf("ingest first batch: %v", err)
|
||||
}
|
||||
ack, err := svc.IngestLogBatch(batch)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest duplicate batch: %v", err)
|
||||
}
|
||||
if !ack.Duplicate || ack.LatestSeq != 2 {
|
||||
t.Fatalf("expected duplicate ack, got %+v", ack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
createLogStreamFixture(t, svc)
|
||||
gap := validLogBatch(t, sessionToken, 2, 2)
|
||||
|
||||
_, err := svc.IngestLogBatch(gap)
|
||||
if err == nil || !strings.Contains(err.Error(), "firstSeq") {
|
||||
t.Fatalf("expected out-of-order rejection, got %v", err)
|
||||
}
|
||||
|
||||
batch := validLogBatch(t, sessionToken, 1, 2)
|
||||
if _, err := svc.IngestLogBatch(batch); err != nil {
|
||||
t.Fatalf("ingest first batch: %v", err)
|
||||
}
|
||||
conflict := batch
|
||||
conflict.Entries[0].Line = "changed"
|
||||
conflict.Checksum = checksumForEntries(t, conflict.Entries)
|
||||
_, err = svc.IngestLogBatch(conflict)
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicts") {
|
||||
t.Fatalf("expected conflicting duplicate rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsMissingLogStream(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||
_, err := svc.IngestLogBatch(validLogBatch(t, sessionToken, 1, 1))
|
||||
if err == nil {
|
||||
t.Fatal("expected missing stream error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) {
|
||||
rootDir := filepath.Join(t.TempDir(), "logs")
|
||||
store, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create file log store: %v", err)
|
||||
}
|
||||
entries := []domain.LogEntry{
|
||||
{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "one"},
|
||||
{Seq: 2, Timestamp: time.Date(2026, 7, 3, 12, 0, 2, 0, time.UTC), Level: "warn", Line: "two"},
|
||||
}
|
||||
record := domain.LogBatchRecord{
|
||||
Checksum: checksumForEntries(t, entries),
|
||||
FirstSeq: 1,
|
||||
LastSeq: 2,
|
||||
Entries: entries,
|
||||
}
|
||||
if err := store.AppendBatch("log-1", record); err != nil {
|
||||
t.Fatalf("append batch: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := NewFileLogBodyStore(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reload file log store: %v", err)
|
||||
}
|
||||
got, exists, err := reloaded.GetBatch("log-1", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("get reloaded batch: %v", err)
|
||||
}
|
||||
if !exists || got.Checksum != record.Checksum || got.LastSeq != 2 {
|
||||
t.Fatalf("unexpected reloaded batch: exists=%v record=%+v", exists, got)
|
||||
}
|
||||
selected, nextSeq, err := reloaded.Query("log-1", 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("query reloaded entries: %v", err)
|
||||
}
|
||||
if len(selected) != 1 || selected[0].Seq != 2 || selected[0].Line != "two" || nextSeq != 2 {
|
||||
t.Fatalf("unexpected reloaded query: entries=%+v next=%d", selected, nextSeq)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-1",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "SCUM #1",
|
||||
}); err != nil {
|
||||
t.Fatalf("create server instance: %v", err)
|
||||
}
|
||||
hello, err := svc.RegisterRunHello(validRunControlHello())
|
||||
if err != nil {
|
||||
t.Fatalf("register run hello: %v", err)
|
||||
}
|
||||
return svc, hello.SessionToken
|
||||
}
|
||||
|
||||
func createLogStreamFixture(t *testing.T, svc *CoreService) domain.LogStream {
|
||||
t.Helper()
|
||||
stream, err := svc.CreateLogStream(domain.LogStream{
|
||||
ID: "log-1",
|
||||
ServerInstanceID: "server-1",
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
StreamKey: "stdout",
|
||||
StorageBackend: domain.LogStorageBackendLocalSegments,
|
||||
RetentionPolicy: "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create log stream: %v", err)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
func validLogBatch(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) domain.LogBatchIngest {
|
||||
t.Helper()
|
||||
entries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
|
||||
for seq := firstSeq; seq <= lastSeq; seq++ {
|
||||
entries = append(entries, domain.LogEntry{
|
||||
Seq: seq,
|
||||
Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC),
|
||||
Level: "info",
|
||||
Line: "line",
|
||||
})
|
||||
}
|
||||
return domain.LogBatchIngest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
LogStreamID: "log-1",
|
||||
ServerInstanceID: "server-1",
|
||||
StreamKey: "stdout",
|
||||
Source: domain.LogStreamSourceProcess,
|
||||
FirstSeq: firstSeq,
|
||||
LastSeq: lastSeq,
|
||||
Compression: "none",
|
||||
Checksum: checksumForEntries(t, entries),
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func checksumForEntries(t *testing.T, entries []domain.LogEntry) string {
|
||||
t.Helper()
|
||||
checksum, err := validator.LogEntriesChecksum(entries)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum entries: %v", err)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import "browser.local/platform/domain"
|
||||
|
||||
func (svc *CoreService) authorizeServerLifecycle(sessionID string, serverInstanceID string) error {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canAccessServer(user, instance) {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) requireServerOwner(sessionID string, serverInstanceID string) (domain.User, domain.ServerInstance, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.ServerInstance{}, err
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.ServerInstance{}, err
|
||||
}
|
||||
if instance.OwnerUserID != user.ID {
|
||||
return domain.User{}, domain.ServerInstance{}, ErrForbidden
|
||||
}
|
||||
return user, instance, nil
|
||||
}
|
||||
|
||||
func canAccessServer(user domain.User, instance domain.ServerInstance) bool {
|
||||
return isPlatformAdmin(user) || instance.OwnerUserID == user.ID || containsString(instance.AdminUserIDs, user.ID)
|
||||
}
|
||||
|
||||
func isPlatformAdmin(user domain.User) bool {
|
||||
for _, role := range user.Roles {
|
||||
switch role {
|
||||
case "admin", "platform-admin", "platformadmin":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
|
||||
create = domain.CopyServerLifecycleCreate(create)
|
||||
if err := validator.ValidateServerLifecycleCreate(create); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
stamp := svc.now()
|
||||
instance := domain.ServerInstance{
|
||||
ID: create.ID,
|
||||
PluginID: create.PluginID,
|
||||
PluginVersion: plugin.Version,
|
||||
RunEndpointID: create.RunEndpointID,
|
||||
Name: create.Name,
|
||||
OwnerUserID: create.OwnerUserID,
|
||||
State: domain.ServerInstanceStateInstalling,
|
||||
ConfigVersion: 1,
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := svc.store.ServerInstances().Create(instance); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
|
||||
Accepted: true,
|
||||
Action: domain.ServerLifecycleActionCreate,
|
||||
Instance: instance,
|
||||
Job: job,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) CreateServerInstanceWorkflowForSession(sessionID string, create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(create.OwnerUserID) == "" {
|
||||
create.OwnerUserID = user.ID
|
||||
}
|
||||
if !isPlatformAdmin(user) && create.OwnerUserID != user.ID {
|
||||
return domain.ServerLifecycleResult{}, ErrForbidden
|
||||
}
|
||||
return svc.CreateServerInstanceWorkflow(create)
|
||||
}
|
||||
|
||||
func (svc *CoreService) StartServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{
|
||||
domain.ServerInstanceStateReady,
|
||||
domain.ServerInstanceStateStopped,
|
||||
})
|
||||
}
|
||||
|
||||
func (svc *CoreService) StartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
return svc.StartServerInstance(command)
|
||||
}
|
||||
|
||||
func (svc *CoreService) StopServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStop, []domain.ServerInstanceState{
|
||||
domain.ServerInstanceStateRunning,
|
||||
})
|
||||
}
|
||||
|
||||
func (svc *CoreService) StopServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
|
||||
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
return svc.StopServerInstance(command)
|
||||
}
|
||||
|
||||
func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLifecycleCommand, action domain.ServerLifecycleAction, allowedStates []domain.ServerInstanceState) (domain.ServerLifecycleResult, error) {
|
||||
command = domain.CopyServerLifecycleCommand(command)
|
||||
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validator.ValidateServerLifecycleAction(action); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if instance.ConfigVersion != command.ExpectedConfigVersion {
|
||||
return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance")
|
||||
}
|
||||
if !serverStateAllowed(instance.State, allowedStates) {
|
||||
return domain.ServerLifecycleResult{}, validationError(fmt.Sprintf("server instance state %q cannot %s", instance.State, action))
|
||||
}
|
||||
|
||||
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateLifecycleActionRef(plugin, action); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
|
||||
job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey)
|
||||
if err != nil {
|
||||
return domain.ServerLifecycleResult{}, err
|
||||
}
|
||||
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
|
||||
Accepted: true,
|
||||
Action: action,
|
||||
Instance: instance,
|
||||
Job: job,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID string) (domain.GamePlugin, domain.RunEndpoint, error) {
|
||||
plugin, err := svc.store.GamePlugins().Get(pluginID)
|
||||
if err != nil {
|
||||
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get plugin dependency: %w", err)
|
||||
}
|
||||
endpoint, err := svc.store.RunEndpoints().Get(runEndpointID)
|
||||
if err != nil {
|
||||
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get run endpoint dependency: %w", err)
|
||||
}
|
||||
return plugin, endpoint, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) {
|
||||
capability := domain.LifecycleCapabilityForAction(action)
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: instance.RunEndpointID,
|
||||
Capability: capability,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID || job.Capability != capability {
|
||||
return domain.Job{}, validationError("idempotencyKey is already used for a different lifecycle target")
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error {
|
||||
existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey)
|
||||
if errors.Is(err, repo.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.ServerInstanceID == serverInstanceID && existing.Capability == capability {
|
||||
return nil
|
||||
}
|
||||
return validationError("idempotencyKey is already used for a different lifecycle target")
|
||||
}
|
||||
|
||||
func validateLifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) error {
|
||||
if strings.TrimSpace(lifecycleActionRef(plugin, action)) == "" {
|
||||
return validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) string {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate:
|
||||
return plugin.LifecycleActions.Install
|
||||
case domain.ServerLifecycleActionStart:
|
||||
return plugin.LifecycleActions.Start
|
||||
case domain.ServerLifecycleActionStop:
|
||||
return plugin.LifecycleActions.Stop
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func serverStateAllowed(state domain.ServerInstanceState, allowed []domain.ServerInstanceState) bool {
|
||||
for _, candidate := range allowed {
|
||||
if state == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lifecycleJobID(serverInstanceID string, action domain.ServerLifecycleAction, idempotencyKey string) string {
|
||||
sum := sha256.Sum256([]byte(idempotencyKey))
|
||||
return fmt.Sprintf("server-lifecycle:%s:%s:%s", serverInstanceID, action, hex.EncodeToString(sum[:8]))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
|
||||
nextState, ok := lifecycleProjectedState(job.Capability, job.State)
|
||||
if !ok || job.ServerInstanceID == "" {
|
||||
return nil
|
||||
}
|
||||
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instance.State = nextState
|
||||
instance.UpdatedAt = stamp
|
||||
if err := validator.ValidateServerInstance(instance); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.store.ServerInstances().Update(instance)
|
||||
}
|
||||
|
||||
func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) {
|
||||
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop {
|
||||
return "", false
|
||||
}
|
||||
if jobState == domain.JobStateFailed || jobState == domain.JobStateCancelled {
|
||||
return domain.ServerInstanceStateFailed, true
|
||||
}
|
||||
if jobState != domain.JobStateSucceeded {
|
||||
return "", false
|
||||
}
|
||||
switch capability {
|
||||
case domain.LifecycleCapabilityInstall:
|
||||
return domain.ServerInstanceStateReady, true
|
||||
case domain.LifecycleCapabilityStart:
|
||||
return domain.ServerInstanceStateRunning, true
|
||||
case domain.LifecycleCapabilityStop:
|
||||
return domain.ServerInstanceStateStopped, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
|
||||
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
|
||||
ID: "server-1",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM #1",
|
||||
IdempotencyKey: "idem-create",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create lifecycle workflow: %v", err)
|
||||
}
|
||||
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
|
||||
t.Fatalf("expected install workflow result, got %+v", created)
|
||||
}
|
||||
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
|
||||
ready, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get ready instance: %v", err)
|
||||
}
|
||||
if ready.State != domain.ServerInstanceStateReady {
|
||||
t.Fatalf("expected install result to mark ready, got %+v", ready)
|
||||
}
|
||||
|
||||
started, err := svc.StartServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: "server-1",
|
||||
ExpectedConfigVersion: ready.ConfigVersion,
|
||||
IdempotencyKey: "idem-start",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start lifecycle workflow: %v", err)
|
||||
}
|
||||
if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart {
|
||||
t.Fatalf("expected start workflow result, got %+v", started)
|
||||
}
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
|
||||
running, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get running instance: %v", err)
|
||||
}
|
||||
if running.State != domain.ServerInstanceStateRunning {
|
||||
t.Fatalf("expected start result to mark running, got %+v", running)
|
||||
}
|
||||
|
||||
stopped, err := svc.StopServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: "server-1",
|
||||
ExpectedConfigVersion: running.ConfigVersion,
|
||||
IdempotencyKey: "idem-stop",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stop lifecycle workflow: %v", err)
|
||||
}
|
||||
if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop {
|
||||
t.Fatalf("expected stop workflow result, got %+v", stopped)
|
||||
}
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
|
||||
final, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get stopped instance: %v", err)
|
||||
}
|
||||
if final.State != domain.ServerInstanceStateStopped {
|
||||
t.Fatalf("expected stop result to mark stopped, got %+v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-ready",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Ready Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create ready server: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.StartServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: instance.ID,
|
||||
ExpectedConfigVersion: instance.ConfigVersion + 1,
|
||||
IdempotencyKey: "idem-stale",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
|
||||
t.Fatalf("expected stale config rejection, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.StopServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: instance.ID,
|
||||
ExpectedConfigVersion: instance.ConfigVersion,
|
||||
IdempotencyKey: "idem-stop-invalid",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot stop") {
|
||||
t.Fatalf("expected invalid stop state rejection, got %v", err)
|
||||
}
|
||||
|
||||
weakEndpoint := endpoint
|
||||
weakEndpoint.ID = "run-no-stop"
|
||||
weakEndpoint.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, "logs.read"}
|
||||
if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil {
|
||||
t.Fatalf("create weak endpoint: %v", err)
|
||||
}
|
||||
running, err := svc.CreateServerInstance(domain.ServerInstance{
|
||||
ID: "server-running",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: weakEndpoint.ID,
|
||||
Name: "Running Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
})
|
||||
if err == nil {
|
||||
_, err = svc.StopServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: running.ID,
|
||||
ExpectedConfigVersion: running.ConfigVersion,
|
||||
IdempotencyKey: "idem-stop-missing-capability",
|
||||
})
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityStop) {
|
||||
t.Fatalf("expected missing stop capability rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceLifecycleFailureProjectsFailedState(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
|
||||
ID: "server-1",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM #1",
|
||||
IdempotencyKey: "idem-create",
|
||||
}); err != nil {
|
||||
t.Fatalf("create lifecycle workflow: %v", err)
|
||||
}
|
||||
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateFailed)
|
||||
instance, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get failed instance: %v", err)
|
||||
}
|
||||
if instance.State != domain.ServerInstanceStateFailed {
|
||||
t.Fatalf("expected failed install result to mark failed, got %+v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServicePluginLifecycleManagesMultipleInstancesIndependently(t *testing.T) {
|
||||
svc, sessionToken := newLifecycleRunService(t)
|
||||
createLifecyclePlugin(t, svc)
|
||||
|
||||
for _, id := range []string{"server-alpha", "server-beta"} {
|
||||
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
|
||||
ID: id,
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: id,
|
||||
IdempotencyKey: "idem-create-" + id,
|
||||
}); err != nil {
|
||||
t.Fatalf("create %s: %v", id, err)
|
||||
}
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, id, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
|
||||
}
|
||||
|
||||
alpha, err := svc.GetServerInstance("server-alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("get alpha: %v", err)
|
||||
}
|
||||
beta, err := svc.GetServerInstance("server-beta")
|
||||
if err != nil {
|
||||
t.Fatalf("get beta: %v", err)
|
||||
}
|
||||
if alpha.State != domain.ServerInstanceStateReady || beta.State != domain.ServerInstanceStateReady || alpha.ID == beta.ID || alpha.PluginID != beta.PluginID {
|
||||
t.Fatalf("expected distinct ready sibling instances, alpha=%+v beta=%+v", alpha, beta)
|
||||
}
|
||||
|
||||
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: alpha.ID,
|
||||
ExpectedConfigVersion: alpha.ConfigVersion,
|
||||
IdempotencyKey: "idem-start-alpha",
|
||||
}); err != nil {
|
||||
t.Fatalf("start alpha: %v", err)
|
||||
}
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
|
||||
|
||||
alpha, _ = svc.GetServerInstance("server-alpha")
|
||||
beta, _ = svc.GetServerInstance("server-beta")
|
||||
if alpha.State != domain.ServerInstanceStateRunning || beta.State != domain.ServerInstanceStateReady {
|
||||
t.Fatalf("expected alpha running and beta unchanged, alpha=%+v beta=%+v", alpha, beta)
|
||||
}
|
||||
|
||||
if _, err := svc.StopServerInstance(domain.ServerLifecycleCommand{
|
||||
ServerInstanceID: alpha.ID,
|
||||
ExpectedConfigVersion: alpha.ConfigVersion,
|
||||
IdempotencyKey: "idem-stop-alpha",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop alpha: %v", err)
|
||||
}
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
|
||||
|
||||
alpha, _ = svc.GetServerInstance("server-alpha")
|
||||
beta, _ = svc.GetServerInstance("server-beta")
|
||||
if alpha.State != domain.ServerInstanceStateStopped || beta.State != domain.ServerInstanceStateReady {
|
||||
t.Fatalf("expected alpha stopped and beta still unchanged, alpha=%+v beta=%+v", alpha, beta)
|
||||
}
|
||||
}
|
||||
|
||||
func newLifecycleRunService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities,
|
||||
domain.LifecycleCapabilityInstall,
|
||||
domain.LifecycleCapabilityStart,
|
||||
domain.LifecycleCapabilityStop,
|
||||
"logs.read",
|
||||
"files.read",
|
||||
)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("register run hello: %v", err)
|
||||
}
|
||||
return svc, hello.SessionToken
|
||||
}
|
||||
|
||||
func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
|
||||
t.Helper()
|
||||
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
|
||||
ID: "server.scum",
|
||||
Name: "SCUM",
|
||||
Version: "1.0.0",
|
||||
ServerType: "scum",
|
||||
ManifestRef: "artifact://manifests/server.scum/1.0.0",
|
||||
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
|
||||
RequiredRunCapabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read"},
|
||||
LifecycleActions: domain.PluginLifecycleActions{
|
||||
Install: "actions/install.json",
|
||||
Start: "actions/start.json",
|
||||
Stop: "actions/stop.json",
|
||||
},
|
||||
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create lifecycle plugin: %v", err)
|
||||
}
|
||||
return plugin
|
||||
}
|
||||
|
||||
func claimAndCompleteLifecycleJob(t *testing.T, svc *CoreService, sessionToken string, capability string, state domain.JobState) {
|
||||
t.Helper()
|
||||
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, "", capability, state)
|
||||
}
|
||||
|
||||
func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessionToken string, serverInstanceID string, capability string, state domain.JobState) {
|
||||
t.Helper()
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
Capabilities: []string{capability},
|
||||
Capacity: domain.RunCapacity{MaxJobs: 4},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim lifecycle job %s: %v", capability, err)
|
||||
}
|
||||
if !claim.HasJob || claim.Job.Capability != capability {
|
||||
t.Fatalf("expected claimed lifecycle job %s, got %+v", capability, claim)
|
||||
}
|
||||
if serverInstanceID != "" && claim.Job.ServerInstanceID != serverInstanceID {
|
||||
t.Fatalf("expected claimed lifecycle job for %s, got %+v", serverInstanceID, claim.Job)
|
||||
}
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: sessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: state,
|
||||
Progress: domain.RunJobProgressReport{Percent: 100, Message: string(state)},
|
||||
Message: string(state),
|
||||
}); err != nil {
|
||||
t.Fatalf("complete lifecycle job %s: %v", capability, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user