init
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type ArtifactMetadata struct {
|
||||
ID string `json:"id"`
|
||||
OwnerKind string `json:"ownerKind"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ArtifactTransferOpenRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Direction string `json:"direction"`
|
||||
OwnerKind string `json:"ownerKind"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ArtifactTransferOpenResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
Direction string `json:"direction"`
|
||||
Artifact ArtifactMetadata `json:"artifact"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Completed bool `json:"completed"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactChunkUploadRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
Offset int64 `json:"offset"`
|
||||
SizeBytes int `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
type ArtifactChunkUploadResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactTransferStatusRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
}
|
||||
|
||||
type ArtifactTransferStatusResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Direction string `json:"direction"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Completed bool `json:"completed"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactTransferCompleteRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
type ArtifactTransferCompleteResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
Artifact ArtifactMetadata `json:"artifact"`
|
||||
Completed bool `json:"completed"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Run Artifact Contract
|
||||
|
||||
Artifacts move files and large payloads between platform and run without blocking logs or control.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/artifacts/open`: opens a run-to-platform upload transfer and returns resume state.
|
||||
- `POST /api/v1/run/artifacts/chunks`: uploads one bounded chunk with byte range and checksum metadata.
|
||||
- `POST /api/v1/run/artifacts/status`: queries received chunks and the next missing chunk index.
|
||||
- `POST /api/v1/run/artifacts/complete`: verifies all chunks and final checksum before marking the artifact available.
|
||||
|
||||
Browser-facing artifact downloads are implemented through platform-owned routes after a run upload completes:
|
||||
|
||||
- `POST /api/v1/artifacts/{id}/download`: returns safe download metadata and a platform content route.
|
||||
- `GET /api/v1/artifacts/{id}/content`: returns bounded byte ranges for authorized browser or plugin-page reads.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `ArtifactTransferOpenRequest`: run ID, session token, artifact ID, upload direction, owner scope, size, chunk size, checksum, and idempotency key.
|
||||
- `ArtifactTransferOpenResponse`: transfer ID, artifact metadata, total chunks, received chunk indexes, next missing chunk index, duplicate flag, and server time.
|
||||
- `ArtifactChunkUploadRequest`: transfer ID, artifact ID, chunk index, byte offset, size, checksum, and JSON byte payload.
|
||||
- `ArtifactChunkUploadResponse`: accepted chunk index, received chunk indexes, next missing chunk index, duplicate flag, and server time.
|
||||
- `ArtifactTransferStatusRequest`: run ID, session token, transfer ID, and artifact ID.
|
||||
- `ArtifactTransferStatusResponse`: transfer direction, total chunks, received chunk indexes, next missing chunk index, completion flag, and server time.
|
||||
- `ArtifactTransferCompleteRequest`: transfer ID, artifact ID, final checksum, and final size.
|
||||
- `ArtifactTransferCompleteResponse`: completed artifact metadata and server time.
|
||||
|
||||
## Local Queue
|
||||
|
||||
Run stores unacknowledged `ArtifactChunkUploadRequest` payloads in the local artifact queue. A queued chunk may be removed only after the platform acknowledges the same transfer ID, artifact ID, and chunk index. The queue must not store or expose raw host paths.
|
||||
|
||||
## Rules
|
||||
|
||||
- Transfers must be resumable.
|
||||
- Transfers must be checksummed.
|
||||
- Artifact concurrency must be limited.
|
||||
- Artifact transfer must not block control heartbeat, job ack/result, or log upload.
|
||||
- Artifact transfer is lower priority than control, job lifecycle metadata, and durable log ingest.
|
||||
- Slow or retrying artifact chunks must not prevent log spool acknowledgement cleanup or terminal job result submission.
|
||||
- Control, job, and log routes must reject artifact chunk payloads or transport details rather than accepting them through lightweight channel payloads.
|
||||
|
||||
Run artifact queues use owner-only atomic JSON entries and retain chunks until an exact transfer/artifact/index acknowledgement covers them. A low-priority uploader retries pending chunks independently of control, jobs, and logs.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Platform-to-run Run self-update range reads are implemented through the signed `/api/v1/run/jobs/update-input` and `/api/v1/run/jobs/update-chunk` contract. Browser artifact upload, external object storage, presigned URLs, production mirrors/signing, and production throttling policies remain separate future work.
|
||||
@@ -0,0 +1,254 @@
|
||||
package protocol
|
||||
|
||||
import "strings"
|
||||
|
||||
type RunAutonomousLifecyclePlan struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
DeploymentRevision int `json:"deploymentRevision,omitempty"`
|
||||
Bootstrap *RunAutonomousLifecycleAction `json:"bootstrap,omitempty"`
|
||||
Actions []RunAutonomousLifecycleAction `json:"actions,omitempty"`
|
||||
DependencyProbes []DependencyProbe `json:"dependencyProbes,omitempty"`
|
||||
InstallPlans []DependencyInstallPlan `json:"installPlans,omitempty"`
|
||||
LogSources []RuntimeLogSourcePlan `json:"logSources,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty"`
|
||||
DataTargets []RunAutonomousDataTarget `json:"dataTargets,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
Deployment *RunAutonomousDeployment `json:"deployment,omitempty"`
|
||||
}
|
||||
|
||||
type RunAutonomousLifecycleAction struct {
|
||||
Action string `json:"action"`
|
||||
Operation string `json:"operation"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
}
|
||||
|
||||
type RunAutonomousDeployment struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Mode string `json:"mode"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
RuntimeBindings map[string]string `json:"runtimeBindings,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRoot string `json:"serverRoot,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
InstallCommand string `json:"installCommand,omitempty"`
|
||||
StartCommand string `json:"startCommand,omitempty"`
|
||||
StopCommand string `json:"stopCommand,omitempty"`
|
||||
StatusCommand string `json:"statusCommand,omitempty"`
|
||||
Shell string `json:"shell,omitempty"`
|
||||
Revision int `json:"revision,omitempty"`
|
||||
}
|
||||
|
||||
type RunAutonomousDataTarget struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
SourceRootKey string `json:"sourceRootKey"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
WorkspaceKey string `json:"workspaceKey"`
|
||||
RefreshPolicy string `json:"refreshPolicy"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
const maxAutonomousDataTargetBytes = int64(1024 * 1024 * 1024)
|
||||
|
||||
func ValidateRunAutonomousLifecyclePlan(plan RunAutonomousLifecyclePlan) error {
|
||||
if plan.SchemaVersion != "1" {
|
||||
return ValidationError("autonomous lifecycle plan schema is unsupported")
|
||||
}
|
||||
if !ValidLogicalFileKey(plan.ServerInstanceID) || !ValidLogicalFileKey(plan.RunEndpointID) || !validAutonomousPluginID(plan.PluginID) {
|
||||
return ValidationError("autonomous lifecycle plan identity is invalid")
|
||||
}
|
||||
if plan.ProfileKey != "" && !ValidLogicalFileKey(plan.ProfileKey) {
|
||||
return ValidationError("autonomous lifecycle profile key is invalid")
|
||||
}
|
||||
if !validAutonomousTarget(plan.TargetOS, plan.TargetArch) {
|
||||
return ValidationError("autonomous lifecycle target platform is invalid")
|
||||
}
|
||||
if plan.TargetRelease != "" && !validAutonomousToken(plan.TargetRelease, 240) {
|
||||
return ValidationError("autonomous lifecycle target release is invalid")
|
||||
}
|
||||
if plan.PluginVersion != "" && !validAutonomousToken(plan.PluginVersion, 120) {
|
||||
return ValidationError("autonomous lifecycle plugin version is invalid")
|
||||
}
|
||||
if plan.Bootstrap != nil {
|
||||
if err := validateAutonomousLifecycleAction(*plan.Bootstrap); err != nil {
|
||||
return err
|
||||
}
|
||||
if plan.Bootstrap.Capability != RunCapabilityProcessInstall && plan.Bootstrap.Capability != RunCapabilityProcessStart {
|
||||
return ValidationError("autonomous lifecycle bootstrap action is invalid")
|
||||
}
|
||||
}
|
||||
if len(plan.Actions) > 16 || len(plan.DependencyProbes) > 64 || len(plan.InstallPlans) > 64 || len(plan.LogSources) > 16 || len(plan.DLLExtensions) > 16 || len(plan.DataTargets) > 16 {
|
||||
return ValidationError("autonomous lifecycle plan is too large")
|
||||
}
|
||||
for _, action := range plan.Actions {
|
||||
if err := validateAutonomousLifecycleAction(action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, probe := range plan.DependencyProbes {
|
||||
if !ValidLogicalFileKey(probe.Key) || !ValidLogicalFileKey(probe.TargetKey) || !validAutonomousToken(probe.Kind, 80) || probe.MinimumVersion != "" && !validAutonomousToken(probe.MinimumVersion, 80) {
|
||||
return ValidationError("autonomous dependency probe is invalid")
|
||||
}
|
||||
}
|
||||
for _, installPlan := range plan.InstallPlans {
|
||||
if !ValidLogicalFileKey(installPlan.Key) || len(installPlan.Steps) > 64 {
|
||||
return ValidationError("autonomous install plan is invalid")
|
||||
}
|
||||
for _, step := range installPlan.Steps {
|
||||
if !ValidLogicalFileKey(step.TargetKey) || !validAutonomousToken(step.Type, 80) || step.PackageManager != "" && !validAutonomousToken(step.PackageManager, 80) || step.PackageName != "" && !validAutonomousToken(step.PackageName, 160) || step.Version != "" && !validAutonomousToken(step.Version, 120) {
|
||||
return ValidationError("autonomous install step is invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, source := range plan.LogSources {
|
||||
if err := validateRuntimeProcessLogSourcePlan(source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seenDataTargets := map[string]struct{}{}
|
||||
seenWorkspaceTargets := map[string]struct{}{}
|
||||
for _, target := range plan.DataTargets {
|
||||
if err := validateAutonomousDataTarget(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seenDataTargets[target.Key]; exists {
|
||||
return ValidationError("autonomous data target key is duplicated")
|
||||
}
|
||||
if _, exists := seenWorkspaceTargets[target.WorkspaceKey]; exists {
|
||||
return ValidationError("autonomous data target workspace is duplicated")
|
||||
}
|
||||
seenDataTargets[target.Key] = struct{}{}
|
||||
seenWorkspaceTargets[target.WorkspaceKey] = struct{}{}
|
||||
}
|
||||
if plan.Deployment != nil {
|
||||
if err := validateAutonomousDeployment(*plan.Deployment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutonomousDataTarget(target RunAutonomousDataTarget) error {
|
||||
if !ValidLogicalFileKey(target.Key) || !ValidLogicalFileKey(target.TransportKey) || !ValidLogicalFileKey(target.SourceRootKey) || !ValidLogicalFileKey(target.SourcePath) || !ValidLogicalFileKey(target.WorkspaceKey) {
|
||||
return ValidationError("autonomous data target is invalid")
|
||||
}
|
||||
if target.Kind != "sqlite.snapshot" || target.RefreshPolicy != "on-demand-snapshot" || !strings.HasPrefix(target.WorkspaceKey, "databases/") {
|
||||
return ValidationError("autonomous data target is invalid")
|
||||
}
|
||||
if target.MaxBytes <= 0 || target.MaxBytes > maxAutonomousDataTargetBytes {
|
||||
return ValidationError("autonomous data target byte limit is invalid")
|
||||
}
|
||||
for _, platform := range target.Platforms {
|
||||
if platform != "windows" && platform != "linux" && platform != "darwin" {
|
||||
return ValidationError("autonomous data target platform is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutonomousLifecycleAction(action RunAutonomousLifecycleAction) error {
|
||||
if !ValidLogicalFileKey(action.TargetKey) || !validAutonomousLifecycleCapability(action.Capability) || !validAutonomousLifecycleName(action.Action) || !validAutonomousLifecycleName(action.Operation) {
|
||||
return ValidationError("autonomous lifecycle action is invalid")
|
||||
}
|
||||
if expected := autonomousCapabilityForAction(action.Action); expected != "" && expected != action.Capability {
|
||||
return ValidationError("autonomous lifecycle action capability mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func autonomousCapabilityForAction(action string) string {
|
||||
switch action {
|
||||
case "create":
|
||||
return RunCapabilityProcessInstall
|
||||
case "start":
|
||||
return RunCapabilityProcessStart
|
||||
case "stop":
|
||||
return RunCapabilityProcessStop
|
||||
case "status":
|
||||
return RunCapabilityProcessStatus
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validAutonomousLifecycleCapability(capability string) bool {
|
||||
switch capability {
|
||||
case RunCapabilityProcessInstall, RunCapabilityProcessStart, RunCapabilityProcessStop, RunCapabilityProcessStatus:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAutonomousLifecycleName(value string) bool {
|
||||
switch value {
|
||||
case "create", "install", "start", "stop", "status":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAutonomousDeployment(deployment RunAutonomousDeployment) error {
|
||||
if deployment.SchemaVersion != "1" || deployment.Mode == "" || deployment.Revision < 0 || deployment.ProfileKey != "" && !ValidLogicalFileKey(deployment.ProfileKey) {
|
||||
return ValidationError("autonomous deployment is invalid")
|
||||
}
|
||||
for key := range deployment.RuntimeBindings {
|
||||
if !ValidLogicalFileKey(key) {
|
||||
return ValidationError("autonomous deployment binding is invalid")
|
||||
}
|
||||
}
|
||||
for key := range deployment.CreateInputs {
|
||||
if !ValidLogicalFileKey(key) {
|
||||
return ValidationError("autonomous deployment input is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAutonomousPluginID(value string) bool {
|
||||
return ValidLogicalFileKey(value)
|
||||
}
|
||||
|
||||
func validAutonomousTarget(osName string, arch string) bool {
|
||||
switch osName {
|
||||
case "windows", "linux", "darwin":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
switch arch {
|
||||
case "amd64", "arm64":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAutonomousToken(value string, maxRunes int) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || trimmed != value || len([]rune(value)) > maxRunes {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
if strings.Contains(value, "..") || strings.Contains(value, `\`) || strings.Contains(value, "://") || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "bearer ") || strings.Contains(lower, "sk-") {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' || char == '-' || char == '.' || char == '/' || char == ':' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRunAutonomousLifecyclePlanAllowsSQLiteSnapshotDataTarget(t *testing.T) {
|
||||
plan := validAutonomousLifecyclePlanForTest()
|
||||
plan.DataTargets = []RunAutonomousDataTarget{{Key: "world-db", Kind: "sqlite.snapshot", TransportKey: "world-db", SourceRootKey: "server-root", SourcePath: "Saved/SaveFiles/world.db", WorkspaceKey: "databases/world-db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 128 * 1024 * 1024, Platforms: []string{"windows"}}}
|
||||
|
||||
if err := ValidateRunAutonomousLifecyclePlan(plan); err != nil {
|
||||
t.Fatalf("expected valid data target plan: %v", err)
|
||||
}
|
||||
|
||||
plan.DataTargets[0].WorkspaceKey = "state/world-db"
|
||||
if err := ValidateRunAutonomousLifecyclePlan(plan); err == nil {
|
||||
t.Fatal("expected non-database workspace target to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAutonomousLifecyclePlanRejectsUnsafeDataTargets(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*RunAutonomousDataTarget){
|
||||
"kind": func(target *RunAutonomousDataTarget) { target.Kind = "scum.sqlite" },
|
||||
"source-path": func(target *RunAutonomousDataTarget) { target.SourcePath = "../current.db" },
|
||||
"refresh-policy": func(target *RunAutonomousDataTarget) { target.RefreshPolicy = "startup" },
|
||||
"max-bytes": func(target *RunAutonomousDataTarget) { target.MaxBytes = maxAutonomousDataTargetBytes + 1 },
|
||||
"platform": func(target *RunAutonomousDataTarget) { target.Platforms = []string{"plan9"} },
|
||||
} {
|
||||
plan := validAutonomousLifecyclePlanForTest()
|
||||
target := RunAutonomousDataTarget{Key: "world-db", Kind: "sqlite.snapshot", TransportKey: "world-db", SourceRootKey: "server-root", SourcePath: "Saved/SaveFiles/world.db", WorkspaceKey: "databases/world-db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 128 * 1024 * 1024, Platforms: []string{"windows"}}
|
||||
mutate(&target)
|
||||
plan.DataTargets = []RunAutonomousDataTarget{target}
|
||||
if err := ValidateRunAutonomousLifecyclePlan(plan); err == nil {
|
||||
t.Fatalf("expected invalid data target %s to be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validAutonomousLifecyclePlanForTest() RunAutonomousLifecyclePlan {
|
||||
return RunAutonomousLifecyclePlan{SchemaVersion: "1", ServerInstanceID: "server-1", PluginID: "game.example", PluginVersion: "1.0.0", RunEndpointID: "run-1", ProfileKey: "run-local", TargetOS: "windows", TargetArch: "amd64", TargetRelease: "release-1", Bootstrap: &RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: RunCapabilityProcessStart, TargetKey: "actions/start.json"}}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type RunCapacityReport struct {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
RunningJobs int `json:"runningJobs"`
|
||||
QueuedJobs int `json:"queuedJobs"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type RunCapabilityReport struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type RunHelloRequest struct {
|
||||
RegistrationToken string `json:"registrationToken"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
ComponentKind string `json:"componentKind,omitempty"`
|
||||
ComponentKey string `json:"componentKey,omitempty"`
|
||||
KeyGeneration int `json:"keyGeneration,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
UpdateJobID string `json:"updateJobId,omitempty"`
|
||||
UpdateOutcome string `json:"updateOutcome,omitempty"`
|
||||
CapabilityReport RunCapabilityReport `json:"capabilityReport"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunHelloResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||
FeatureFlags []string `json:"featureFlags,omitempty"`
|
||||
}
|
||||
|
||||
type RunHeartbeatRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CapabilityFingerprint string `json:"capabilityFingerprint"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunHeartbeatResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"`
|
||||
RefreshCapabilities bool `json:"refreshCapabilities"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunLifecycleReportRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Capability string `json:"capability"`
|
||||
State string `json:"state"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ManagedProcessID string `json:"managedProcessId,omitempty"`
|
||||
ObservationSeq uint64 `json:"observationSeq,omitempty"`
|
||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
||||
ExecutionResult RunJobExecutionResult `json:"executionResult,omitempty"`
|
||||
}
|
||||
|
||||
type RunLifecycleReportResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProjectedState string `json:"projectedState,omitempty"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Run Control Contract
|
||||
|
||||
Control is the lightweight high-priority channel between run and platform.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/control/hello`: registers run metadata and receives a platform-issued session token.
|
||||
- `POST /api/v1/run/control/heartbeat`: reports status, capacity, and capability fingerprint using the active session token.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `RunHelloRequest`: registration token, run ID, display name, version, status, platform, capability summary, and capacity summary.
|
||||
- `RunHelloResponse`: session token, server time, polling hints, and feature flags.
|
||||
- `RunHeartbeatRequest`: session token, version, status, capacity, and current capability fingerprint.
|
||||
- `RunHeartbeatResponse`: accepted status, next heartbeat interval, and optional capability refresh request.
|
||||
- `RunCapabilityReport`: capability names and compact fingerprint metadata.
|
||||
- `RunCapacityReport`: max jobs, active jobs, queued jobs, and local resource summary.
|
||||
|
||||
## Rules
|
||||
|
||||
- Control payloads must be small.
|
||||
- Control must not carry logs, artifact chunks, or long job result bodies.
|
||||
- Control must have priority over job execution, log upload, and artifact transfer.
|
||||
- Heartbeat capacity summaries must remain metadata-only and must not mention or carry heavy channel payloads.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Durable log ingest, artifact chunk transfer, and the optional game client bridge remain separate channels. The job channel is separate from control and uses `/api/v1/run/jobs/*` routes.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Game Client Bridge Contract
|
||||
|
||||
The game client bridge is optional and exists only for games that need in-game command execution or snapshots.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `ClientHello`: game client credential, server instance ID, version, and display name.
|
||||
- `ClientHeartbeat`: session token, version, status, and game connection status.
|
||||
- `ClientCommandPoll`: session token and batch limit.
|
||||
- `ClientCommandResult`: command ID, status, bounded output, and timestamp.
|
||||
- `ClientSnapshot`: snapshot mode, raw bounded text or structured data reference, and timestamp.
|
||||
|
||||
## Rules
|
||||
|
||||
- The bridge must not carry run lifecycle jobs.
|
||||
- The bridge must not carry run log ingest batches.
|
||||
- Games without in-game bridge needs should not enable this channel.
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RunCapabilityProcessInstall = "process.install"
|
||||
RunCapabilityProcessStart = "process.start"
|
||||
RunCapabilityProcessStop = "process.stop"
|
||||
RunCapabilityProcessStatus = "process.status"
|
||||
RunCapabilityLogsRead = "logs.read"
|
||||
RunCapabilityConfigWrite = "config.write"
|
||||
RunCapabilityFilesList = "files.list"
|
||||
RunCapabilityFilesRead = "files.read"
|
||||
RunCapabilityFilesWrite = "files.write"
|
||||
RunCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||
RunCapabilityRemoteFTPWrite = "remote.ftp.write"
|
||||
RunCapabilityRemoteRsyncRead = "remote.rsync.read"
|
||||
RunCapabilityRemoteRsyncWrite = "remote.rsync.write"
|
||||
RunCapabilityRemoteRunFilesRead = "remote.run.files.read"
|
||||
RunCapabilityRemoteRunFilesWrite = "remote.run.files.write"
|
||||
RunCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||
RunCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||
RunCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||
RunCapabilityRemoteRunDBSQLiteProbe = "remote.run.db.sqlite.probe"
|
||||
RunCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
RunCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
RunCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
RunCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
|
||||
RunCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
|
||||
RunCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
RunCapabilityRunSelfUpdate = "run.self-update"
|
||||
RunCapabilityDistributionBuild = "distribution.build"
|
||||
RunCapabilityDependenciesCheck = "dependencies.check"
|
||||
RunCapabilityDependenciesInstall = "dependencies.install"
|
||||
RunCapabilityLogsBackfill = "logs.backfill"
|
||||
RunCapabilityDeploymentPlan = "deployment.plan.v1"
|
||||
)
|
||||
|
||||
type RunJobProgressReport struct {
|
||||
Percent int `json:"percent"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobExecutionInput struct {
|
||||
WorkspaceScope string `json:"workspaceScope,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ExpectedVersion int `json:"expectedVersion,omitempty"`
|
||||
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
|
||||
MaxReadBytes int `json:"maxReadBytes,omitempty"`
|
||||
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
|
||||
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
LogSource *RuntimeLogSourcePlan `json:"logSource,omitempty"`
|
||||
LogSources []RuntimeLogSourcePlan `json:"logSources,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty"`
|
||||
SourceRCON *RuntimeSourceRCONPlan `json:"sourceRcon,omitempty"`
|
||||
SQLiteSchemaProbe *SQLiteSchemaProbeRequest `json:"sqliteSchemaProbe,omitempty"`
|
||||
Deployment *ServerDeploymentExecution `json:"deployment,omitempty"`
|
||||
ServerDeploymentPlan *ServerDeploymentPlan `json:"serverDeploymentPlan,omitempty"`
|
||||
}
|
||||
|
||||
// SQLiteSchemaProbeBinding identifies the package and current database binding
|
||||
// that authorized a probe. It contains only logical identities, never a path,
|
||||
// DSN, socket, or credential.
|
||||
type SQLiteSchemaProbeBinding struct {
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunBindingID string `json:"runBindingId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion"`
|
||||
AdapterVersion string `json:"adapterVersion"`
|
||||
GameVersion string `json:"gameVersion,omitempty"`
|
||||
DatabaseIdentity string `json:"databaseIdentity"`
|
||||
}
|
||||
|
||||
// SQLiteSchemaProbeLimits cap every independent part of diagnostic output.
|
||||
// They are applied by Run even when the caller asks for larger values.
|
||||
type SQLiteSchemaProbeLimits struct {
|
||||
MaxObjects int `json:"maxObjects"`
|
||||
MaxColumnsPerObject int `json:"maxColumnsPerObject"`
|
||||
MaxIndexesPerObject int `json:"maxIndexesPerObject"`
|
||||
MaxForeignKeys int `json:"maxForeignKeys"`
|
||||
MaxCardinalityReads int `json:"maxCardinalityReads"`
|
||||
MaxSampleRows int `json:"maxSampleRows"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
MaxResultBytes int `json:"maxResultBytes"`
|
||||
}
|
||||
|
||||
// SQLiteSchemaProbeRequest requests generic, query-only SQLite metadata for
|
||||
// assignment.TargetKey. TargetKey is always resolved inside the scoped package
|
||||
// workspace; this request intentionally has no path or SQL field.
|
||||
type SQLiteSchemaProbeRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Binding SQLiteSchemaProbeBinding `json:"binding"`
|
||||
Limits SQLiteSchemaProbeLimits `json:"limits"`
|
||||
}
|
||||
|
||||
type SQLiteSchemaProbeColumn struct {
|
||||
NameFingerprint string `json:"nameFingerprint"`
|
||||
DeclaredType string `json:"declaredType"`
|
||||
Nullable *bool `json:"nullable,omitempty"`
|
||||
PrimaryKey bool `json:"primaryKey"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
}
|
||||
|
||||
type SQLiteSchemaProbeIndex struct {
|
||||
NameFingerprint string `json:"nameFingerprint"`
|
||||
Unique bool `json:"unique"`
|
||||
ColumnHashes []string `json:"columnHashes"`
|
||||
}
|
||||
|
||||
type SQLiteSchemaProbeForeignKey struct {
|
||||
FromColumnHash string `json:"fromColumnHash"`
|
||||
ToObjectHash string `json:"toObjectHash"`
|
||||
ToColumnHash string `json:"toColumnHash"`
|
||||
}
|
||||
|
||||
type SQLiteSchemaProbeObject struct {
|
||||
ObjectHash string `json:"objectHash"`
|
||||
Kind string `json:"kind"`
|
||||
NameFingerprint string `json:"nameFingerprint"`
|
||||
DeclaredColumns []SQLiteSchemaProbeColumn `json:"declaredColumns"`
|
||||
Indexes []SQLiteSchemaProbeIndex `json:"indexes"`
|
||||
ForeignKeys []SQLiteSchemaProbeForeignKey `json:"foreignKeys"`
|
||||
ApproximateRows *int64 `json:"approximateRows,omitempty"`
|
||||
SampleFingerprints []string `json:"sampleFingerprints,omitempty"`
|
||||
}
|
||||
|
||||
type SQLiteSchemaProbeSafeError struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
// SQLiteSchemaProbeResult is a terminal, redacted envelope. Names and sample
|
||||
// values are represented only by salted-looking SHA-256 fingerprints.
|
||||
type SQLiteSchemaProbeResult struct {
|
||||
RequestID string `json:"requestId"`
|
||||
JobID string `json:"jobId"`
|
||||
Binding SQLiteSchemaProbeBinding `json:"binding"`
|
||||
Status string `json:"status"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
SourceFingerprint string `json:"sourceFingerprint,omitempty"`
|
||||
SchemaFingerprint string `json:"schemaFingerprint,omitempty"`
|
||||
ResultDigest string `json:"resultDigest,omitempty"`
|
||||
Objects []SQLiteSchemaProbeObject `json:"objects,omitempty"`
|
||||
SafeError SQLiteSchemaProbeSafeError `json:"safeError,omitempty"`
|
||||
Limits SQLiteSchemaProbeLimits `json:"limits"`
|
||||
}
|
||||
|
||||
type ServerDeploymentExecution struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Mode string `json:"mode"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
CreateInputs map[string]string `json:"createInputs,omitempty"`
|
||||
ServerRoot string `json:"serverRoot,omitempty"`
|
||||
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
||||
StartCommand string `json:"startCommand,omitempty"`
|
||||
StopCommand string `json:"stopCommand,omitempty"`
|
||||
StatusCommand string `json:"statusCommand,omitempty"`
|
||||
Shell string `json:"shell,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
}
|
||||
|
||||
// ServerDeploymentPlan is kept only for backward-compatible decoding of
|
||||
// legacy assignments. Game-specific deployment plans are no longer executed by
|
||||
// Run; plugins own game lifecycle policy through action assets.
|
||||
type ServerDeploymentPlan struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Operation string `json:"operation"`
|
||||
PluginID string `json:"pluginId"`
|
||||
TemplateKey string `json:"templateKey"`
|
||||
TemplateVersion string `json:"templateVersion"`
|
||||
SteamAppID string `json:"steamAppId"`
|
||||
ExecutableKey string `json:"executableKey"`
|
||||
InstallRootKey string `json:"installRootKey"`
|
||||
ConfigKey string `json:"configKey"`
|
||||
ConfigFormat string `json:"configFormat"`
|
||||
Prerequisites []RuntimeServerPrerequisite `json:"prerequisites,omitempty"`
|
||||
ConfigMappings []RuntimeServerConfigMapping `json:"configMappings"`
|
||||
DiscoveryMarkers []RuntimeServerDiscoveryMarker `json:"discoveryMarkers"`
|
||||
VerificationChecks []RuntimeServerVerificationCheck `json:"verificationChecks"`
|
||||
}
|
||||
|
||||
type RuntimeServerPrerequisite struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type RuntimeServerConfigMapping struct {
|
||||
FieldKey string `json:"fieldKey"`
|
||||
ConfigKey string `json:"configKey"`
|
||||
ValueType string `json:"valueType"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type RuntimeServerDiscoveryMarker struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
Expected string `json:"expected,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type RuntimeServerVerificationCheck struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type ServerDeploymentEvidence struct {
|
||||
TemplateKey string `json:"templateKey,omitempty"`
|
||||
TemplateVersion string `json:"templateVersion,omitempty"`
|
||||
PreflightState string `json:"preflightState,omitempty"`
|
||||
DiscoveryState string `json:"discoveryState,omitempty"`
|
||||
MappingState string `json:"mappingState,omitempty"`
|
||||
VerificationState string `json:"verificationState,omitempty"`
|
||||
DiscoveredFacts map[string]string `json:"discoveredFacts,omitempty"`
|
||||
MappingResults map[string]string `json:"mappingResults,omitempty"`
|
||||
VerificationResults map[string]string `json:"verificationResults,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeDLLExtensionPlan is a Platform-frozen UE4SS DLL release. It is only
|
||||
// accepted as part of a scoped process.start job; Run never fetches a mutable
|
||||
// plugin declaration on its own.
|
||||
type RuntimeDLLExtensionPlan struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
DLLRef string `json:"dllRef"`
|
||||
SCUMExecutableChecksum string `json:"scumExecutableChecksum"`
|
||||
UE4SSABI string `json:"ue4ssAbi"`
|
||||
RCONPort int `json:"rconPort"`
|
||||
}
|
||||
|
||||
// RuntimeLogSourcePlan is a Platform-frozen, logical file log declaration. It
|
||||
// carries no host paths; Run resolves TargetKey only inside its scoped
|
||||
// workspace for the server instance.
|
||||
type RuntimeLogSourcePlan struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
CursorKind string `json:"cursorKind,omitempty"`
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeSourceRCONPlan contains only frozen, non-secret metadata. Run reads
|
||||
// the generated password from the scoped workspace after it consumes the
|
||||
// one-time command input.
|
||||
type RuntimeSourceRCONPlan struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ExtensionKey string `json:"extensionKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
ConfigRef string `json:"configRef"`
|
||||
DeploymentStateRef string `json:"deploymentStateRef"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type RunJobExecutionResult struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ProcessState string `json:"processState,omitempty"`
|
||||
ExitClassification string `json:"exitClassification,omitempty"`
|
||||
ExitCode int `json:"exitCode,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
SQLiteSchemaProbe *SQLiteSchemaProbeResult `json:"sqliteSchemaProbe,omitempty"`
|
||||
DeploymentReceipt *ServerDeploymentExecutionReceipt `json:"deploymentReceipt,omitempty"`
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidence `json:"serverDeploymentEvidence,omitempty"`
|
||||
}
|
||||
|
||||
type ServerDeploymentExecutionReceipt struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Revision int `json:"revision"`
|
||||
Action string `json:"action"`
|
||||
Mode string `json:"mode"`
|
||||
Shell string `json:"shell,omitempty"`
|
||||
UsedServerRoot bool `json:"usedServerRoot"`
|
||||
}
|
||||
|
||||
type RunJobAssignment struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State string `json:"state"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
ExecutionInput RunJobExecutionInput `json:"executionInput,omitempty"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
FencingToken uint64 `json:"fencingToken,omitempty"`
|
||||
Attempt int `json:"attempt"`
|
||||
LogSessionID string `json:"logSessionId,omitempty"`
|
||||
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
AckDeadlineAt time.Time `json:"ackDeadlineAt,omitempty"`
|
||||
LeaseExpiresAt time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt,omitempty"`
|
||||
ProgressSequence uint64 `json:"progressSequence,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RunJobClaimRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunJobClaimResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
HasJob bool `json:"hasJob"`
|
||||
Job *RunJobAssignment `json:"job,omitempty"`
|
||||
NextPollSeconds int `json:"nextPollSeconds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobAckRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobAckResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobProgressRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
Sequence uint64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobProgressResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobResultRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
State string `json:"state"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
ExecutionResult RunJobExecutionResult `json:"executionResult,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobResultResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type DistributionBuildInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type DistributionBuildInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ComponentKind string `json:"componentKind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
PlatformURL string `json:"platformUrl,omitempty"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
RepositoryURL string `json:"repositoryUrl,omitempty"`
|
||||
SourceRevision string `json:"sourceRevision,omitempty"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
OutputFilename string `json:"outputFilename"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
WorkspaceSeed string `json:"workspaceSeed,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyExecutionInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type DependencyProbe struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
MinimumVersion string `json:"minimumVersion,omitempty"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyInstallStep struct {
|
||||
Type string `json:"type"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
PackageManager string `json:"packageManager,omitempty"`
|
||||
PackageName string `json:"packageName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
DownloadRef string `json:"downloadRef,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyInstallPlan struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
Steps []DependencyInstallStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type DependencyExecutionInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
PlanDigest string `json:"planDigest"`
|
||||
Probe DependencyProbe `json:"probe,omitempty"`
|
||||
Plan DependencyInstallPlan `json:"plan,omitempty"`
|
||||
Bindings map[string]string `json:"bindings"`
|
||||
}
|
||||
|
||||
type SourceRCONExecutionInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
// SourceRCONExecutionInput is deliberately excluded from assignments and
|
||||
// journals. Command is returned once to the active Run lease only.
|
||||
type SourceRCONExecutionInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ProtectedRequestExecutionInput is deliberately excluded from assignments and
|
||||
// journals. Platform returns the approved text and its logical binding once to
|
||||
// the active, fenced Run lease; no connection material crosses this channel.
|
||||
type ProtectedRequestExecutionInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
}
|
||||
|
||||
type ProtectedRequestExecutionInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
Authorized bool `json:"authorized"`
|
||||
ApprovalState string `json:"approvalState"`
|
||||
QueueState string `json:"queueState"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
RequestText string `json:"requestText"`
|
||||
}
|
||||
|
||||
type RunUpdateInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type RunUpdateInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
PackageFormat string `json:"packageFormat"`
|
||||
ExecutableName string `json:"executableName"`
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
}
|
||||
|
||||
type RunUpdateChunkRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Offset int64 `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
type RunUpdateChunkResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Offset int64 `json:"offset"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Payload []byte `json:"payload"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type RunUpdateHealthRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Outcome string `json:"outcome"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type RunUpdateHealthResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
JobID string `json:"jobId"`
|
||||
Phase string `json:"phase"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type DependencyExecutionEvidence struct {
|
||||
ProbeKey string `json:"probeKey"`
|
||||
PlanKey string `json:"planKey,omitempty"`
|
||||
PlanDigest string `json:"planDigest"`
|
||||
State string `json:"state"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
CompletedSteps int `json:"completedSteps,omitempty"`
|
||||
}
|
||||
|
||||
type RunUpdateExecutionEvidence struct {
|
||||
TargetRelease string `json:"targetRelease"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
type RunJobCancelPollRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type RunJobCancelPollResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
HasCancel bool `json:"hasCancel"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequestedAt time.Time `json:"requestedAt,omitempty"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobReconcileEntry struct {
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type RunJobReconcileRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ActiveJobs []RunJobReconcileEntry `json:"activeJobs"`
|
||||
}
|
||||
|
||||
type RunJobReconcileResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ConfirmedJobs []RunJobAssignment `json:"confirmedJobs"`
|
||||
DiscardJobIDs []string `json:"discardJobIds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# Run Job Contract
|
||||
|
||||
Jobs execute bounded server management work.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/jobs/claim`: claims one queued job for the registered run endpoint.
|
||||
- `POST /api/v1/run/jobs/ack`: acknowledges an active leased job before execution.
|
||||
- `POST /api/v1/run/jobs/progress`: reports bounded progress for an active leased job.
|
||||
- `POST /api/v1/run/jobs/result`: submits a bounded terminal result for an active leased job.
|
||||
- `POST /api/v1/run/jobs/cancel`: polls platform cancellation requests for active leased jobs.
|
||||
- `POST /api/v1/run/jobs/reconcile`: reconciles platform-known active jobs after run restart or reconnect.
|
||||
- `POST /api/v1/run/jobs/dependency-input`: loads a typed dependency probe/plan only for the active fenced attempt.
|
||||
- `POST /api/v1/run/jobs/update-input`: loads approved same-server target-matched Run distribution metadata only for the active fenced attempt.
|
||||
- `POST /api/v1/run/jobs/update-chunk`: reads one bounded resumable update artifact range; this lower-priority transfer route never carries browser download tokens or storage paths.
|
||||
- `POST /api/v1/run/jobs/update-health`: reports a post-registration/post-reconciliation update success or rollback outcome through the current signed session.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `RunJobClaimRequest`: session token, run ID, capacity, and supported capabilities.
|
||||
- `RunJobClaimResponse`: optional job assignment with identity, capability, server instance, logical target key, scoped input ref, idempotency key, per-job attempt, max attempts, raw one-use lease token, ack deadline, execution lease deadline, and polling hint. Platform persists only the lease hash.
|
||||
- `RunJobAckRequest`: job ID, run ID, session token, lease token, attempt, and bounded message.
|
||||
- `RunJobProgressRequest`: job ID, run ID, session token, lease token, attempt, percent, sequence, and bounded message.
|
||||
- `RunJobResultRequest`: job ID, run ID, session token, lease token, attempt, terminal state, progress, bounded message, error code, retryable flag, and result reference.
|
||||
- `RunJobCancelPollRequest`: run ID, session token, job ID, lease token, and attempt.
|
||||
- `RunJobReconcileRequest`: run ID, session token, and active journal entries containing job ID, lease token, and attempt. The response confirms matching attempts and returns stale/unknown IDs to discard.
|
||||
- Dependency input contains only declared probe/plan summaries, logical bindings, target OS/architecture, and a reviewed plan digest. Update input/chunk responses contain only artifact ID, target, checksum, bounded range metadata, and payload bytes.
|
||||
- Update health contains job ID, attempt/lease proof, outcome, and release version. Platform accepts success only after the terminal staged result and current online endpoint registration; Run does not claim success from a hello-only outcome.
|
||||
|
||||
## Local Journal
|
||||
|
||||
Run persists a versioned journal under its owner-only workspace state directory. Assignment writes are atomic and happen before acknowledgement. A locally completed terminal result is also persisted without the Run session token before transport; after restart, confirmed attempts replay that result instead of executing again. A staged self-update keeps its activation manifest alongside the pending result so a crash cannot silently discard helper activation. Entries are removed only after the platform accepts the result or reconciliation explicitly discards them. Registration is followed by reconciliation before new claims, including after session rotation.
|
||||
|
||||
## Lifecycle Executor
|
||||
|
||||
The runtime worker executes these bounded lifecycle job capabilities:
|
||||
|
||||
- `process.install`
|
||||
- `process.start`
|
||||
- `process.stop`
|
||||
|
||||
Platform-dispatched config/file jobs are now represented in the run job payload and validated before execution by later worker implementations:
|
||||
|
||||
- `config.write`: writes approved config content addressed by a logical config key plus scoped `input://...` ref.
|
||||
- `files.read`: reads a declared logical file key and returns results through bounded metadata or artifact refs.
|
||||
- `files.write`: writes content addressed by a logical file key plus scoped `input://...` or `artifact://...` ref.
|
||||
|
||||
Plugin-declared remote access jobs use the same job channel and remain bounded metadata envelopes:
|
||||
|
||||
- `remote.ftp.read` / `remote.ftp.write`: platform-mediated FTP file transfer requests.
|
||||
- `remote.rsync.read` / `remote.rsync.write`: platform-mediated rsync file transfer requests.
|
||||
- `remote.run.files.read` / `remote.run.files.write`: run-mediated logical file operations.
|
||||
- `remote.run.process.start` / `remote.run.process.stop`: run-mediated remote process lifecycle operations.
|
||||
- `remote.run.db.mysql.query` / `remote.run.db.sqlite.query`: run-mediated database read envelopes with scoped input refs for query payloads.
|
||||
- `remote.run.logs.transfer`: run-mediated log transfer through log/artifact channels.
|
||||
- `remote.run.rcon.command`: run-mediated RCON command envelopes with scoped input refs.
|
||||
|
||||
Run distribution and runtime support jobs use the same lightweight job lifecycle:
|
||||
|
||||
- `run.self-update`: downloads an approved same-server target-matched distribution in bounded resumable ranges, verifies the final checksum, safely extracts exactly the expected executable, preserves config, and reports a rollback-safe staged result. A helper activates only after result acceptance, then waits for health and restores the previous binary on timeout.
|
||||
- `dependencies.check`: runs a plugin-declared typed dependency probe addressed by a logical `dependencies/...` key.
|
||||
- `dependencies.install`: runs only an approved typed install plan addressed by `dependencies/install/...`; package, verified HTTPS download, SteamCMD, and manual steps are closed adapters, and arbitrary shell snippets, unsafe URLs/tokens, and unsupported targets are rejected.
|
||||
- `logs.backfill`: advances historical log cursors for declared process, file, FTP, SQL, or plugin-specific sources and returns bounded cursor/result refs instead of log bodies.
|
||||
|
||||
The executor resolves lifecycle action templates under the scoped server workspace and runs direct command/argument vectors through the process supervisor. It does not run unrestricted shell strings, execute arbitrary plugin code, expose host paths, return raw credentials, open direct sockets, or embed logs/artifacts in job result payloads.
|
||||
|
||||
## Rules
|
||||
|
||||
- Job ack must be sent before execution.
|
||||
- Ack deadline and execution lease timestamps are platform-authoritative. Progress and confirmed reconciliation renew only the current fenced attempt.
|
||||
- Ack timeout, lease expiry, and retryable results follow the platform's bounded retry/backoff policy; Run never invents a replacement attempt locally.
|
||||
- Cancellation polling is fenced and cancellation results are ordinary idempotent terminal results.
|
||||
- Terminal result must be replayable while the journal retains the job.
|
||||
- Large files must be passed as artifact references, not embedded in job payloads.
|
||||
- Config/file job payloads must use logical target keys and scoped input/artifact refs.
|
||||
- Remote database and RCON jobs must use scoped input/artifact refs rather than embedding query or command bodies in job results.
|
||||
- Run self-update, dependency, and log backfill jobs must use declared capabilities, logical target keys, scoped refs, and bounded result refs.
|
||||
- Job payloads must not include logs, artifact chunks, raw host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||
- Process stdout/stderr must be redacted and written to the log spool rather than embedded in progress/result bodies.
|
||||
- Job ack/progress/result/cancel/reconcile calls are lightweight lifecycle metadata and must be able to complete while artifact/file transfer work is active or retrying.
|
||||
- Dependency adapters and update downloads run in the job worker while control heartbeat, cancellation polling, log spool upload, and artifact upload retain independent bounded loops.
|
||||
- Terminal results must remain idempotent under log and artifact retry pressure and must reference artifacts by safe `artifact://...` refs rather than embedding transfer payloads.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Durable log ingest, artifact chunk transfer, and optional game client bridge traffic remain separate channels and must not be multiplexed through job result payloads. Artifact transfer carries chunk payloads only through `/api/v1/run/artifacts/*` routes.
|
||||
|
||||
Production code signing/KMS, rollout rings/fleet orchestration, client-manager lifecycle, plugin lifecycle, production scaling/alerts, and real AI-provider integration are explicitly outside this contract.
|
||||
@@ -0,0 +1,520 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maxRunLogicalFileKeyLength = 160
|
||||
const maxRunExecutionContentBytes = 64 * 1024
|
||||
const maxRunDLLExtensionBytes = int64(128 * 1024 * 1024)
|
||||
const maxSourceRCONTimeoutSeconds = 60
|
||||
const maxProtectedRequestTimeoutSeconds = 120
|
||||
const maxProtectedRequestTextBytes = 16 * 1024
|
||||
const (
|
||||
maxSQLiteSchemaProbeObjects = 512
|
||||
maxSQLiteSchemaProbeColumnsPerObject = 256
|
||||
maxSQLiteSchemaProbeIndexesPerObject = 128
|
||||
maxSQLiteSchemaProbeForeignKeys = 128
|
||||
maxSQLiteSchemaProbeCardinalityReads = 512
|
||||
maxSQLiteSchemaProbeSamples = 3
|
||||
maxSQLiteSchemaProbeTimeoutMS = 10000
|
||||
maxSQLiteSchemaProbeResultBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func ValidateRunJobAssignment(assignment RunJobAssignment) error {
|
||||
if assignment.JobID == "" || assignment.RunEndpointID == "" || assignment.Capability == "" {
|
||||
return ValidationError("jobId, runEndpointId, and capability are required")
|
||||
}
|
||||
switch assignment.Capability {
|
||||
case RunCapabilityConfigWrite, RunCapabilityFilesList, RunCapabilityFilesRead, RunCapabilityFilesWrite:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for scoped file jobs")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) {
|
||||
return ValidationError("targetKey is not allowed")
|
||||
}
|
||||
}
|
||||
switch assignment.Capability {
|
||||
case RunCapabilityConfigWrite, RunCapabilityFilesWrite:
|
||||
if !ValidScopedInputRef(assignment.InputRef) {
|
||||
return ValidationError("inputRef is not allowed")
|
||||
}
|
||||
}
|
||||
if len([]byte(assignment.ExecutionInput.Content)) > maxRunExecutionContentBytes {
|
||||
return ValidationError("execution input content is too large")
|
||||
}
|
||||
if assignment.ExecutionInput.MaxReadBytes < 0 || assignment.ExecutionInput.MaxReadBytes > maxRunExecutionContentBytes {
|
||||
return ValidationError("execution input maxReadBytes is out of bounds")
|
||||
}
|
||||
if assignment.ExecutionInput.WorkspaceScope != "" && !ValidLogicalFileKey(assignment.ExecutionInput.WorkspaceScope) {
|
||||
return ValidationError("execution input workspaceScope is not allowed")
|
||||
}
|
||||
if assignment.ExecutionInput.ServerDeploymentPlan != nil {
|
||||
return ValidationError("game-specific server deployment plans are legacy unsupported input")
|
||||
}
|
||||
if len(assignment.ExecutionInput.DLLExtensions) > 0 {
|
||||
if assignment.Capability != RunCapabilityProcessStart || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" {
|
||||
return ValidationError("DLL extensions are allowed only for scoped process.start jobs")
|
||||
}
|
||||
if len(assignment.ExecutionInput.DLLExtensions) > 16 {
|
||||
return ValidationError("too many DLL extensions are declared")
|
||||
}
|
||||
keys := make(map[string]struct{}, len(assignment.ExecutionInput.DLLExtensions))
|
||||
targets := make(map[string]struct{}, len(assignment.ExecutionInput.DLLExtensions))
|
||||
for _, plan := range assignment.ExecutionInput.DLLExtensions {
|
||||
if err := validateRuntimeDLLExtensionPlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := keys[plan.Key]; exists {
|
||||
return ValidationError("DLL extension key is duplicated")
|
||||
}
|
||||
if _, exists := targets[plan.TargetKey]; exists {
|
||||
return ValidationError("DLL extension target is duplicated")
|
||||
}
|
||||
keys[plan.Key] = struct{}{}
|
||||
targets[plan.TargetKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
if assignment.ExecutionInput.LogSource != nil {
|
||||
if assignment.Capability != RunCapabilityLogsBackfill || assignment.ServerInstanceID == "" {
|
||||
return ValidationError("log source is allowed only for logs.backfill jobs")
|
||||
}
|
||||
if err := validateRuntimeLogSourcePlan(*assignment.ExecutionInput.LogSource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(assignment.ExecutionInput.LogSources) > 0 {
|
||||
if assignment.Capability != RunCapabilityProcessStart || assignment.ServerInstanceID == "" {
|
||||
return ValidationError("process log sources are allowed only for process.start jobs")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, source := range assignment.ExecutionInput.LogSources {
|
||||
if err := validateRuntimeProcessLogSourcePlan(source); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seen[source.Kind]; exists {
|
||||
return ValidationError("process log source kind is duplicated")
|
||||
}
|
||||
seen[source.Kind] = struct{}{}
|
||||
}
|
||||
}
|
||||
if assignment.ExecutionInput.SourceRCON != nil {
|
||||
isSourceCommand := assignment.Capability == RunCapabilityRemoteRunRCONCommand
|
||||
isProtectedRCON := assignment.Capability == RunCapabilityRemoteRunProtectedRCON
|
||||
if (!isSourceCommand && !isProtectedRCON) || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" {
|
||||
return ValidationError("Source RCON is allowed only for scoped remote.run.rcon.command or remote.run.protected.rcon jobs")
|
||||
}
|
||||
if assignment.MaxAttempts != 1 {
|
||||
return ValidationError("Source RCON jobs must have exactly one attempt")
|
||||
}
|
||||
if isSourceCommand && !strings.HasPrefix(assignment.InputRef, "input://source-rcon/") {
|
||||
return ValidationError("Source RCON inputRef must be a source-rcon input ref")
|
||||
}
|
||||
if isProtectedRCON && !strings.HasPrefix(assignment.InputRef, "input://protected-request/") {
|
||||
return ValidationError("protected Source RCON inputRef must be a protected-request input ref")
|
||||
}
|
||||
wantAdapterKind := "rcon"
|
||||
if isProtectedRCON {
|
||||
wantAdapterKind = protectedRequestAdapterKind(assignment.Capability)
|
||||
}
|
||||
if assignment.ExecutionInput.RemoteAdapterKind != "" && assignment.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
||||
return ValidationError("Source RCON requires the rcon adapter kind")
|
||||
}
|
||||
maxTimeout := maxSourceRCONTimeoutSeconds
|
||||
if isProtectedRCON {
|
||||
maxTimeout = maxProtectedRequestTimeoutSeconds
|
||||
}
|
||||
if assignment.ExecutionInput.TimeoutSeconds < 1 || assignment.ExecutionInput.TimeoutSeconds > maxTimeout {
|
||||
return ValidationError("Source RCON timeout is out of bounds")
|
||||
}
|
||||
if err := validateRuntimeSourceRCONPlan(*assignment.ExecutionInput.SourceRCON); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if assignment.ExecutionInput.SQLiteSchemaProbe != nil {
|
||||
if assignment.Capability != RunCapabilityRemoteRunDBSQLiteProbe || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" || assignment.MaxAttempts != 1 || assignment.FencingToken == 0 {
|
||||
return ValidationError("SQLite schema probe requires a scoped single fenced probe job")
|
||||
}
|
||||
if assignment.InputRef != "" || assignment.ExecutionInput.RemoteAdapterKey != "" || assignment.ExecutionInput.RemoteAdapterKind != "" || assignment.ExecutionInput.Content != "" || len(assignment.ExecutionInput.Inputs) != 0 {
|
||||
return ValidationError("SQLite schema probe must not carry adapter input or content")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "databases/") {
|
||||
return ValidationError("SQLite schema probe target must be a logical database target")
|
||||
}
|
||||
if err := validateSQLiteSchemaProbeRequest(*assignment.ExecutionInput.SQLiteSchemaProbe, assignment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if isProtectedRequestCapability(assignment.Capability) {
|
||||
if assignment.ServerInstanceID == "" || assignment.MaxAttempts != 1 || assignment.FencingToken == 0 {
|
||||
return ValidationError("protected requests require a scoped single fenced attempt")
|
||||
}
|
||||
if !strings.HasPrefix(assignment.InputRef, "input://protected-request/") {
|
||||
return ValidationError("protected request inputRef is not allowed")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) || !ValidLogicalFileKey(assignment.ExecutionInput.RemoteAdapterKey) {
|
||||
return ValidationError("protected request logical binding is not allowed")
|
||||
}
|
||||
if assignment.ExecutionInput.RemoteAdapterKind != protectedRequestAdapterKind(assignment.Capability) {
|
||||
return ValidationError("protected request adapter kind does not match capability")
|
||||
}
|
||||
if assignment.ExecutionInput.TimeoutSeconds < 1 || assignment.ExecutionInput.TimeoutSeconds > maxProtectedRequestTimeoutSeconds {
|
||||
return ValidationError("protected request timeout is out of bounds")
|
||||
}
|
||||
if assignment.ExecutionInput.Content != "" {
|
||||
return ValidationError("protected request text must not be in a job assignment")
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(assignment.InputRef, "input://source-rcon/") && assignment.ExecutionInput.SourceRCON == nil {
|
||||
return ValidationError("source-rcon inputRef requires a Source RCON plan")
|
||||
}
|
||||
if IsRemoteCapability(assignment.Capability) {
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for remote jobs")
|
||||
}
|
||||
if RemoteCapabilityRequiresTargetKey(assignment.Capability) && !ValidLogicalFileKey(assignment.TargetKey) {
|
||||
return ValidationError("targetKey is not allowed")
|
||||
}
|
||||
if RemoteCapabilityRequiresInputRef(assignment.Capability) && !ValidScopedInputRef(assignment.InputRef) {
|
||||
return ValidationError("inputRef is not allowed")
|
||||
}
|
||||
if assignment.ExecutionInput.RemoteAdapterKey != "" && !ValidLogicalFileKey(assignment.ExecutionInput.RemoteAdapterKey) {
|
||||
return ValidationError("remoteAdapterKey is not allowed")
|
||||
}
|
||||
if assignment.ExecutionInput.RemoteAdapterKind != "" && !validRemoteAdapterKind(assignment.ExecutionInput.RemoteAdapterKind) {
|
||||
return ValidationError("remoteAdapterKind is not allowed")
|
||||
}
|
||||
if assignment.ExecutionInput.TimeoutSeconds < 0 || assignment.ExecutionInput.TimeoutSeconds > 300 {
|
||||
return ValidationError("remote adapter timeout is out of bounds")
|
||||
}
|
||||
}
|
||||
if assignment.Capability == RunCapabilityRemoteRunDBSQLiteProbe && assignment.ExecutionInput.SQLiteSchemaProbe == nil {
|
||||
return ValidationError("SQLite schema probe request is required")
|
||||
}
|
||||
switch assignment.Capability {
|
||||
case RunCapabilityDistributionBuild:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for distribution build jobs")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "distribution/") {
|
||||
return ValidationError("targetKey is not allowed for distribution build jobs")
|
||||
}
|
||||
if !ValidScopedInputRef(assignment.InputRef) || !strings.HasPrefix(assignment.InputRef, "input://distribution-build/") {
|
||||
return ValidationError("inputRef must be a distribution build input ref")
|
||||
}
|
||||
case RunCapabilityRunSelfUpdate:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for self-update jobs")
|
||||
}
|
||||
if assignment.TargetKey != "run/update" {
|
||||
return ValidationError("targetKey must be run/update")
|
||||
}
|
||||
if !ValidScopedInputRef(assignment.InputRef) || !strings.HasPrefix(assignment.InputRef, "artifact://") {
|
||||
return ValidationError("inputRef must be an artifact ref for self-update")
|
||||
}
|
||||
case RunCapabilityDependenciesCheck, RunCapabilityDependenciesInstall:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for dependency jobs")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "dependencies/") {
|
||||
return ValidationError("targetKey is not allowed for dependency jobs")
|
||||
}
|
||||
if assignment.InputRef != "" {
|
||||
return ValidationError("dependency jobs must not carry arbitrary input refs")
|
||||
}
|
||||
case RunCapabilityLogsBackfill:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for log backfill jobs")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "logs/") {
|
||||
return ValidationError("targetKey is not allowed for log backfill jobs")
|
||||
}
|
||||
if assignment.InputRef != "" && !ValidScopedInputRef(assignment.InputRef) {
|
||||
return ValidationError("inputRef is not allowed for log backfill jobs")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidProtectedRequestExecutionInput(value ProtectedRequestExecutionInputResponse) bool {
|
||||
return value.JobID != "" && value.ServerInstanceID != "" && value.RunEndpointID != "" && value.FencingToken != 0 && value.Authorized && value.ApprovalState == "approved" && value.QueueState == "claimed" && !value.ExpiresAt.IsZero() && time.Now().UTC().Before(value.ExpiresAt) && validProtectedRequestKind(value.Kind) && ValidLogicalFileKey(value.TransportKey) && ValidLogicalFileKey(value.TargetKey) && validProtectedRequestText(value.RequestText)
|
||||
}
|
||||
|
||||
func validProtectedRequestText(value string) bool {
|
||||
return strings.TrimSpace(value) != "" && utf8.ValidString(value) && len([]byte(value)) <= maxProtectedRequestTextBytes && !strings.ContainsRune(value, '\x00')
|
||||
}
|
||||
|
||||
func isProtectedRequestCapability(capability string) bool {
|
||||
return protectedRequestAdapterKind(capability) != ""
|
||||
}
|
||||
|
||||
func IsProtectedRequestCapability(capability string) bool {
|
||||
return isProtectedRequestCapability(capability)
|
||||
}
|
||||
|
||||
func protectedRequestAdapterKind(capability string) string {
|
||||
switch capability {
|
||||
case RunCapabilityRemoteRunProtectedSQL:
|
||||
return "protected-sql"
|
||||
case RunCapabilityRemoteRunProtectedRCON:
|
||||
return "protected-rcon"
|
||||
case RunCapabilityRemoteRunProgram:
|
||||
return "protected-program"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validProtectedRequestKind(kind string) bool {
|
||||
return kind == "sql" || kind == "rcon" || kind == "program"
|
||||
}
|
||||
|
||||
func validRemoteAdapterKind(kind string) bool {
|
||||
switch kind {
|
||||
case "ftp", "rsync", "run-file", "run-process", "database", "rcon", "log-transfer", "protected-sql", "protected-rcon", "protected-program":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateSQLiteSchemaProbeRequest(request SQLiteSchemaProbeRequest, assignment RunJobAssignment) error {
|
||||
if !validProbeIdentifier(request.RequestID) {
|
||||
return ValidationError("SQLite schema probe requestId is not allowed")
|
||||
}
|
||||
binding := request.Binding
|
||||
if binding.ServerInstanceID != assignment.ServerInstanceID || binding.RunEndpointID != assignment.RunEndpointID || !validProbeIdentifier(binding.RunBindingID) || !validProbeIdentifier(binding.PluginID) || !validProbeIdentifier(binding.PluginVersion) || !validProbeIdentifier(binding.AdapterVersion) || !validProbeIdentifier(binding.DatabaseIdentity) {
|
||||
return ValidationError("SQLite schema probe binding is invalid")
|
||||
}
|
||||
if binding.GameVersion != "" && !validProbeIdentifier(binding.GameVersion) {
|
||||
return ValidationError("SQLite schema probe gameVersion is invalid")
|
||||
}
|
||||
if err := validateSQLiteSchemaProbeLimits(request.Limits); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSQLiteSchemaProbeLimits(limits SQLiteSchemaProbeLimits) error {
|
||||
if limits.MaxObjects < 1 || limits.MaxObjects > maxSQLiteSchemaProbeObjects || limits.MaxColumnsPerObject < 1 || limits.MaxColumnsPerObject > maxSQLiteSchemaProbeColumnsPerObject || limits.MaxIndexesPerObject < 0 || limits.MaxIndexesPerObject > maxSQLiteSchemaProbeIndexesPerObject || limits.MaxForeignKeys < 0 || limits.MaxForeignKeys > maxSQLiteSchemaProbeForeignKeys || limits.MaxCardinalityReads < 0 || limits.MaxCardinalityReads > maxSQLiteSchemaProbeCardinalityReads || limits.MaxSampleRows < 0 || limits.MaxSampleRows > maxSQLiteSchemaProbeSamples || limits.TimeoutMS < 1 || limits.TimeoutMS > maxSQLiteSchemaProbeTimeoutMS || limits.MaxResultBytes < 1 || limits.MaxResultBytes > maxSQLiteSchemaProbeResultBytes {
|
||||
return ValidationError("SQLite schema probe limits are out of bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validProbeIdentifier(value string) bool {
|
||||
return ValidLogicalFileKey(value) && !strings.Contains(value, "/")
|
||||
}
|
||||
|
||||
func validateRuntimeDLLExtensionPlan(plan RuntimeDLLExtensionPlan) error {
|
||||
if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.TargetKey) || !validExtensionVersion(plan.Version) {
|
||||
return ValidationError("DLL extension identity is not allowed")
|
||||
}
|
||||
if !validRuntimeDLLURL(plan.ReleaseURL) || !validSHA256(plan.Checksum) || !validSHA256(plan.SCUMExecutableChecksum) || plan.SizeBytes < 1 || plan.SizeBytes > maxRunDLLExtensionBytes {
|
||||
return ValidationError("DLL extension release integrity is not allowed")
|
||||
}
|
||||
if !validDLLModKey(plan.ModKey) || plan.DLLRef != "ue4ss/Mods/"+plan.ModKey+"/dlls/main.dll" {
|
||||
return ValidationError("DLL extension deployment path is not allowed")
|
||||
}
|
||||
if !validUE4SSABI(plan.UE4SSABI) || plan.RCONPort < 1024 || plan.RCONPort > 65535 {
|
||||
return ValidationError("DLL extension compatibility metadata is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRuntimeLogSourcePlan(plan RuntimeLogSourcePlan) error {
|
||||
if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.StreamKey) || !ValidLogicalFileKey(plan.TargetKey) {
|
||||
return ValidationError("log source identity is not allowed")
|
||||
}
|
||||
if plan.Kind != "file.tail" {
|
||||
return ValidationError("log source kind is unsupported")
|
||||
}
|
||||
switch plan.CursorKind {
|
||||
case "", "offset", "fingerprint":
|
||||
default:
|
||||
return ValidationError("log source cursor kind is unsupported")
|
||||
}
|
||||
if plan.RetentionDays < 0 || plan.RetentionDays > 365 {
|
||||
return ValidationError("log source retention is out of bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRuntimeProcessLogSourcePlan(plan RuntimeLogSourcePlan) error {
|
||||
if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.StreamKey) {
|
||||
return ValidationError("process log source identity is not allowed")
|
||||
}
|
||||
switch plan.Kind {
|
||||
case "process.stdout", "process.stderr":
|
||||
default:
|
||||
return ValidationError("process log source kind is unsupported")
|
||||
}
|
||||
if plan.TargetKey != "" && !ValidLogicalFileKey(plan.TargetKey) {
|
||||
return ValidationError("process log source target is not allowed")
|
||||
}
|
||||
switch plan.CursorKind {
|
||||
case "", "sequence":
|
||||
default:
|
||||
return ValidationError("process log source cursor kind is unsupported")
|
||||
}
|
||||
if plan.RetentionDays < 0 || plan.RetentionDays > 365 {
|
||||
return ValidationError("process log source retention is out of bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRuntimeSourceRCONPlan(plan RuntimeSourceRCONPlan) error {
|
||||
if plan.Protocol != "source-rcon" || !ValidLogicalFileKey(plan.ExtensionKey) || !validDLLModKey(plan.ModKey) {
|
||||
return ValidationError("Source RCON identity is not allowed")
|
||||
}
|
||||
if plan.ConfigRef != "ue4ss/Mods/"+plan.ModKey+"/config.ini" || !ValidLogicalFileKey(plan.ConfigRef) {
|
||||
return ValidationError("Source RCON config reference is not allowed")
|
||||
}
|
||||
if !validSourceRCONDeploymentStateRef(plan.DeploymentStateRef) {
|
||||
return ValidationError("Source RCON deployment state reference is not allowed")
|
||||
}
|
||||
if plan.Port < 1024 || plan.Port > 65535 {
|
||||
return ValidationError("Source RCON port is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validSourceRCONDeploymentStateRef(value string) bool {
|
||||
const prefix = "runtime/ue4ss-dll/"
|
||||
const suffix = "/release.json"
|
||||
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
|
||||
return false
|
||||
}
|
||||
targetKey := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)
|
||||
return targetKey != "" && ValidLogicalFileKey(targetKey)
|
||||
}
|
||||
|
||||
func validRuntimeDLLURL(value string) bool {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Port() != "" && parsed.Port() != "443" || parsed.RawQuery != "" || !strings.HasSuffix(strings.ToLower(parsed.Path), ".dll") {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
|
||||
return false
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validExtensionVersion(value string) bool {
|
||||
if len(value) == 0 || len(value) > 80 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validDLLModKey(value string) bool {
|
||||
if len(value) == 0 || len(value) > 80 {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '_' || char == '-' {
|
||||
if index > 0 || char != '_' && char != '-' {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUE4SSABI(value string) bool {
|
||||
if len(value) == 0 || len(value) > 80 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ValidationError string
|
||||
|
||||
func (err ValidationError) Error() string { return string(err) }
|
||||
|
||||
func ValidLogicalFileKey(key string) bool {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" || trimmed != key || len([]rune(key)) > maxRunLogicalFileKeyLength {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") {
|
||||
return false
|
||||
}
|
||||
for _, char := range key {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ValidScopedInputRef(ref string) bool {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
lower := strings.ToLower(ref)
|
||||
if trimmed == "" || trimmed != ref || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
||||
}
|
||||
|
||||
func IsRemoteCapability(capability string) bool {
|
||||
return strings.HasPrefix(capability, "remote.")
|
||||
}
|
||||
|
||||
func RemoteCapabilityRequiresTargetKey(capability string) bool {
|
||||
switch capability {
|
||||
case RunCapabilityRemoteRunProcessStart, RunCapabilityRemoteRunProcessStop:
|
||||
return false
|
||||
default:
|
||||
return IsRemoteCapability(capability)
|
||||
}
|
||||
}
|
||||
|
||||
func RemoteCapabilityRequiresInputRef(capability string) bool {
|
||||
switch capability {
|
||||
case RunCapabilityRemoteFTPWrite,
|
||||
RunCapabilityRemoteRsyncWrite,
|
||||
RunCapabilityRemoteRunFilesWrite,
|
||||
RunCapabilityRemoteRunDBMySQLQuery,
|
||||
RunCapabilityRemoteRunDBSQLiteQuery,
|
||||
RunCapabilityRemoteRunRCONCommand,
|
||||
RunCapabilityRemoteRunProtectedSQL,
|
||||
RunCapabilityRemoteRunProtectedRCON,
|
||||
RunCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateRunJobAssignmentScopedFilePayloads(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-config-write",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityConfigWrite,
|
||||
TargetKey: "server.properties",
|
||||
InputRef: "input://server-config/server-1/server.properties/v1",
|
||||
IdempotencyKey: "idem-config",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid config write assignment: %v", err)
|
||||
}
|
||||
|
||||
assignment.TargetKey = "/Users/tasia/server.properties"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||
t.Fatalf("expected raw host path rejection, got %v", err)
|
||||
}
|
||||
|
||||
assignment.TargetKey = "server.properties"
|
||||
assignment.InputRef = "sk-raw-secret"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||
t.Fatalf("expected raw credential ref rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentScopedReadDoesNotRequireInputRef(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-files-read",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityFilesRead,
|
||||
TargetKey: "logs/latest.log",
|
||||
IdempotencyKey: "idem-read",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid file read assignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentRequiresBoundedSQLiteSchemaProbe(t *testing.T) {
|
||||
assignment := RunJobAssignment{JobID: "job-probe", ServerInstanceID: "server-1", RunEndpointID: "run-1", Capability: RunCapabilityRemoteRunDBSQLiteProbe, TargetKey: "databases/current.db", IdempotencyKey: "idem-probe", MaxAttempts: 1, FencingToken: 1, ExecutionInput: RunJobExecutionInput{WorkspaceScope: "profile-1", SQLiteSchemaProbe: &SQLiteSchemaProbeRequest{RequestID: "probe-1", Binding: SQLiteSchemaProbeBinding{ServerInstanceID: "server-1", RunBindingID: "binding-1", RunEndpointID: "run-1", PluginID: "game.example", PluginVersion: "1.0.0", AdapterVersion: "adapter-1", DatabaseIdentity: "database-1"}, Limits: SQLiteSchemaProbeLimits{MaxObjects: 8, MaxColumnsPerObject: 8, MaxIndexesPerObject: 8, MaxForeignKeys: 8, MaxCardinalityReads: 8, MaxSampleRows: 2, TimeoutMS: 1000, MaxResultBytes: 1024}}}}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected bounded SQLite schema probe to validate: %v", err)
|
||||
}
|
||||
assignment.ExecutionInput.SQLiteSchemaProbe.Limits.MaxSampleRows = 4
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "limits") {
|
||||
t.Fatalf("expected oversized sample limit rejection, got %v", err)
|
||||
}
|
||||
assignment.ExecutionInput.SQLiteSchemaProbe.Limits.MaxSampleRows = 2
|
||||
assignment.ExecutionInput.Content = "SELECT * FROM private"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "must not carry") {
|
||||
t.Fatalf("expected embedded query rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentRejectsLegacyGameSpecificDeploymentPlan(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-legacy-scum-plan",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityProcessInstall,
|
||||
IdempotencyKey: "idem-legacy-scum",
|
||||
ExecutionInput: RunJobExecutionInput{
|
||||
Deployment: &ServerDeploymentExecution{SchemaVersion: "1", Mode: "guided-install", ServerRoot: "C:/scumserver", Revision: 1},
|
||||
ServerDeploymentPlan: &ServerDeploymentPlan{SchemaVersion: "1", PluginID: "game.scum", TemplateKey: "scum-steamcmd-windows"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "legacy unsupported") {
|
||||
t.Fatalf("expected legacy game-specific plan rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentAllowsDeclaredProcessLogSourcesOnlyForStart(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-process-logs",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityProcessStart,
|
||||
TargetKey: "actions/start.json",
|
||||
IdempotencyKey: "idem-process-logs",
|
||||
ExecutionInput: RunJobExecutionInput{LogSources: []RuntimeLogSourcePlan{
|
||||
{Key: "console-stdout", Kind: "process.stdout", TargetKey: "process/server", StreamKey: "game.console.stdout", CursorKind: "sequence", RetentionDays: 30},
|
||||
{Key: "console-stderr", Kind: "process.stderr", TargetKey: "process/server", StreamKey: "game.console.stderr", CursorKind: "sequence", RetentionDays: 30},
|
||||
}},
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected process log sources to validate: %v", err)
|
||||
}
|
||||
|
||||
assignment.ExecutionInput.LogSources[0].Kind = "file.tail"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "process log source kind") {
|
||||
t.Fatalf("expected file log source rejection on process.start, got %v", err)
|
||||
}
|
||||
|
||||
assignment.ExecutionInput.LogSources[0].Kind = "process.stdout"
|
||||
assignment.Capability = RunCapabilityProcessStop
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "process log sources") {
|
||||
t.Fatalf("expected process log source rejection outside start, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentRemoteCapabilitiesAreBounded(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-remote-rcon",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityRemoteRunRCONCommand,
|
||||
TargetKey: "rcon/command",
|
||||
InputRef: "input://server-1/rcon/command/1",
|
||||
IdempotencyKey: "idem-rcon",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid remote rcon assignment: %v", err)
|
||||
}
|
||||
|
||||
assignment.InputRef = "password=raw"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||
t.Fatalf("expected unsafe inputRef rejection, got %v", err)
|
||||
}
|
||||
|
||||
assignment.InputRef = "input://server-1/rcon/command/1"
|
||||
assignment.TargetKey = "/Users/tasia/server.db"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||
t.Fatalf("expected unsafe targetKey rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentDistributionCapabilitiesAreBounded(t *testing.T) {
|
||||
selfUpdate := RunJobAssignment{
|
||||
JobID: "job-update",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityRunSelfUpdate,
|
||||
TargetKey: "run/update",
|
||||
InputRef: "artifact://artifact-run-latest",
|
||||
IdempotencyKey: "idem-update",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(selfUpdate); err != nil {
|
||||
t.Fatalf("expected valid self-update assignment: %v", err)
|
||||
}
|
||||
selfUpdate.InputRef = "input://not-an-artifact"
|
||||
if err := ValidateRunJobAssignment(selfUpdate); err == nil || !strings.Contains(err.Error(), "artifact") {
|
||||
t.Fatalf("expected non-artifact self-update ref rejection, got %v", err)
|
||||
}
|
||||
|
||||
check := RunJobAssignment{
|
||||
JobID: "job-dependency-check",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityDependenciesCheck,
|
||||
TargetKey: "dependencies/java-21",
|
||||
IdempotencyKey: "idem-dep-check",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(check); err != nil {
|
||||
t.Fatalf("expected valid dependency check assignment: %v", err)
|
||||
}
|
||||
check.TargetKey = "dependencies/install/java;rm"
|
||||
if err := ValidateRunJobAssignment(check); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||
t.Fatalf("expected shell-like dependency target rejection, got %v", err)
|
||||
}
|
||||
|
||||
backfill := RunJobAssignment{
|
||||
JobID: "job-log-backfill",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityLogsBackfill,
|
||||
TargetKey: "logs/latest-log",
|
||||
InputRef: "artifact://logs/checkpoint/1",
|
||||
IdempotencyKey: "idem-log-backfill",
|
||||
ExecutionInput: RunJobExecutionInput{LogSource: &RuntimeLogSourcePlan{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest.log", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}},
|
||||
}
|
||||
if err := ValidateRunJobAssignment(backfill); err != nil {
|
||||
t.Fatalf("expected valid log backfill assignment: %v", err)
|
||||
}
|
||||
backfill.ExecutionInput.LogSource.Kind = "sql.query"
|
||||
if err := ValidateRunJobAssignment(backfill); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("expected unsupported log source rejection, got %v", err)
|
||||
}
|
||||
backfill.ExecutionInput.LogSource.Kind = "file.tail"
|
||||
backfill.InputRef = "password=raw"
|
||||
if err := ValidateRunJobAssignment(backfill); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||
t.Fatalf("expected unsafe log checkpoint rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentDLLExtensionsAreBounded(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-dll-extension",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityProcessStart,
|
||||
TargetKey: "actions/start.json",
|
||||
IdempotencyKey: "idem-dll-extension",
|
||||
ExecutionInput: RunJobExecutionInput{
|
||||
WorkspaceScope: "run-local",
|
||||
DLLExtensions: []RuntimeDLLExtensionPlan{validRuntimeDLLExtensionPlan()},
|
||||
},
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid DLL extension assignment: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*RunJobAssignment)
|
||||
want string
|
||||
}{
|
||||
{name: "not process start", mutate: func(value *RunJobAssignment) { value.Capability = RunCapabilityProcessStop }, want: "process.start"},
|
||||
{name: "query URL", mutate: func(value *RunJobAssignment) { value.ExecutionInput.DLLExtensions[0].ReleaseURL += "?release=1" }, want: "release integrity"},
|
||||
{name: "unsafe DLL path", mutate: func(value *RunJobAssignment) {
|
||||
value.ExecutionInput.DLLExtensions[0].DLLRef = "ue4ss/Mods/scum_simple_rcon/dlls/other.dll"
|
||||
}, want: "deployment path"},
|
||||
{name: "bad checksum", mutate: func(value *RunJobAssignment) { value.ExecutionInput.DLLExtensions[0].Checksum = "sha256:bad" }, want: "release integrity"},
|
||||
{name: "missing scope", mutate: func(value *RunJobAssignment) { value.ExecutionInput.WorkspaceScope = "" }, want: "process.start"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
value := assignment
|
||||
value.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...)
|
||||
testCase.mutate(&value)
|
||||
if err := ValidateRunJobAssignment(value); err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("expected %q validation error, got %v", testCase.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentSourceRCONPlanIsFrozenAndOneShot(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-source-rcon",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityRemoteRunRCONCommand,
|
||||
TargetKey: "rcon.password",
|
||||
InputRef: "input://source-rcon/job-source-rcon",
|
||||
IdempotencyKey: "idem-source-rcon",
|
||||
Attempt: 1,
|
||||
MaxAttempts: 1,
|
||||
ExecutionInput: RunJobExecutionInput{
|
||||
WorkspaceScope: "run-local",
|
||||
RemoteAdapterKey: "rcon",
|
||||
RemoteAdapterKind: "rcon",
|
||||
TimeoutSeconds: 30,
|
||||
SourceRCON: &RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: "scum-simple-rcon", ModKey: "scum_simple_rcon", ConfigRef: "ue4ss/Mods/scum_simple_rcon/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/ue4ss/scum-simple-rcon/release.json", Port: 27015},
|
||||
},
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid frozen Source RCON plan: %v", err)
|
||||
}
|
||||
protected := assignment
|
||||
protected.Capability = RunCapabilityRemoteRunProtectedRCON
|
||||
protected.TargetKey = "scum-management"
|
||||
protected.InputRef = "input://protected-request/job-protected-rcon"
|
||||
protected.FencingToken = 7
|
||||
protected.ExecutionInput.RemoteAdapterKey = "scum-management"
|
||||
protected.ExecutionInput.RemoteAdapterKind = "protected-rcon"
|
||||
protected.ExecutionInput.TimeoutSeconds = 120
|
||||
plan := *assignment.ExecutionInput.SourceRCON
|
||||
protected.ExecutionInput.SourceRCON = &plan
|
||||
if err := ValidateRunJobAssignment(protected); err != nil {
|
||||
t.Fatalf("expected valid protected Source RCON plan: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*RunJobAssignment)
|
||||
want string
|
||||
}{
|
||||
{name: "multiple attempts", mutate: func(value *RunJobAssignment) { value.MaxAttempts = 2 }, want: "exactly one attempt"},
|
||||
{name: "wrong capability", mutate: func(value *RunJobAssignment) { value.Capability = RunCapabilityRemoteRunDBSQLiteQuery }, want: "remote.run.rcon.command or remote.run.protected.rcon"},
|
||||
{name: "unsafe config reference", mutate: func(value *RunJobAssignment) {
|
||||
value.ExecutionInput.SourceRCON.ConfigRef = "ue4ss/Mods/scum_simple_rcon/other.ini"
|
||||
}, want: "config reference"},
|
||||
{name: "unsafe deployment state reference", mutate: func(value *RunJobAssignment) {
|
||||
value.ExecutionInput.SourceRCON.DeploymentStateRef = "runtime/ue4ss-dll/../../release.json"
|
||||
}, want: "deployment state reference"},
|
||||
{name: "unsafe port", mutate: func(value *RunJobAssignment) { value.ExecutionInput.SourceRCON.Port = 80 }, want: "port"},
|
||||
{name: "ordinary input ref", mutate: func(value *RunJobAssignment) { value.InputRef = "input://server-1/rcon/command" }, want: "source-rcon input ref"},
|
||||
{name: "missing plan", mutate: func(value *RunJobAssignment) { value.ExecutionInput.SourceRCON = nil }, want: "requires a Source RCON plan"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
value := assignment
|
||||
plan := *assignment.ExecutionInput.SourceRCON
|
||||
value.ExecutionInput.SourceRCON = &plan
|
||||
testCase.mutate(&value)
|
||||
if err := ValidateRunJobAssignment(value); err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("expected %q validation error, got %v", testCase.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProtectedRequestAssignmentAndOneTimeInput(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-protected",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityRemoteRunProtectedSQL,
|
||||
TargetKey: "scum-database",
|
||||
InputRef: "input://protected-request/job-protected",
|
||||
IdempotencyKey: "protected-1",
|
||||
LeaseToken: "lease-protected",
|
||||
FencingToken: 7,
|
||||
Attempt: 1,
|
||||
MaxAttempts: 1,
|
||||
ExecutionInput: RunJobExecutionInput{
|
||||
RemoteAdapterKey: "scum-database",
|
||||
RemoteAdapterKind: "protected-sql",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid protected assignment: %v", err)
|
||||
}
|
||||
input := ProtectedRequestExecutionInputResponse{JobID: assignment.JobID, ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID, FencingToken: assignment.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: time.Now().UTC().Add(time.Minute), Kind: "sql", TransportKey: "scum-database", TargetKey: assignment.TargetKey, RequestText: "SELECT player_id FROM players LIMIT 1"}
|
||||
if !ValidProtectedRequestExecutionInput(input) {
|
||||
t.Fatal("expected approved unexpired protected input")
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mutate func(*RunJobAssignment)
|
||||
want string
|
||||
}{
|
||||
{name: "missing fence", mutate: func(value *RunJobAssignment) { value.FencingToken = 0 }, want: "fenced"},
|
||||
{name: "multiple attempts", mutate: func(value *RunJobAssignment) { value.MaxAttempts = 2 }, want: "single"},
|
||||
{name: "wrong input ref", mutate: func(value *RunJobAssignment) { value.InputRef = "input://ordinary/request" }, want: "inputRef"},
|
||||
{name: "wrong adapter", mutate: func(value *RunJobAssignment) { value.ExecutionInput.RemoteAdapterKind = "database" }, want: "adapter kind"},
|
||||
{name: "inline text", mutate: func(value *RunJobAssignment) { value.ExecutionInput.Content = "SELECT secret" }, want: "must not"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
value := assignment
|
||||
testCase.mutate(&value)
|
||||
if err := ValidateRunJobAssignment(value); err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("expected %q validation error, got %v", testCase.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
input.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||
if ValidProtectedRequestExecutionInput(input) {
|
||||
t.Fatal("expired protected input was accepted")
|
||||
}
|
||||
input.ExpiresAt = time.Now().UTC().Add(time.Minute)
|
||||
input.QueueState = "pending"
|
||||
if ValidProtectedRequestExecutionInput(input) {
|
||||
t.Fatal("unclaimed protected input was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func validRuntimeDLLExtensionPlan() RuntimeDLLExtensionPlan {
|
||||
return RuntimeDLLExtensionPlan{
|
||||
Key: "scum-simple-rcon",
|
||||
Version: "1.0.0",
|
||||
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll",
|
||||
Checksum: "sha256:" + strings.Repeat("a", 64),
|
||||
SizeBytes: 1024,
|
||||
TargetKey: "ue4ss/scum-simple-rcon",
|
||||
ModKey: "scum_simple_rcon",
|
||||
DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
||||
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64),
|
||||
UE4SSABI: "ue4ss-3.0",
|
||||
RCONPort: 27015,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Run Log Ingest Contract
|
||||
|
||||
Logs are durable historical data. They are not transported as best-effort UI messages.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: uploads one bounded log batch and receives an acknowledgement range.
|
||||
- `POST /api/v1/log-streams/query`: queries stored log entries after a stream sequence cursor.
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`: browser-facing Server-Sent Events stream for replaying recent stored entries and pushing newly ingested platform log entries.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `LogBatchIngestRequest`: run ID, session token, server instance ID, stream ID, source, sequence range, compression metadata, checksum, and bounded entries.
|
||||
- `LogEntry`: sequence, timestamp, level, line, parser metadata, and redaction state.
|
||||
- `LogBatchIngestResponse`: accepted sequence range, latest acknowledged sequence, duplicate flag, retry hint, and server time.
|
||||
- `LogStreamCursorRequest`: stream ID, sequence cursor, and limit.
|
||||
- `LogStreamCursorResponse`: ordered entries, next cursor, and latest acknowledged sequence.
|
||||
- `LogStreamEventResponse`: safe browser event containing server ID, stream metadata, latest sequence, and one log entry.
|
||||
|
||||
Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonomous lifecycle bootstrap is not a Platform job, so it uses `run.<runEndpointId>.<serverInstanceId>.<streamKey>` and Platform creates the server-bound stream from the signed Run batch instead of looking for a job record.
|
||||
|
||||
## Local Spool
|
||||
|
||||
Run must write unacknowledged logs to a local spool/WAL before upload. Segments may be removed only after platform acknowledgement.
|
||||
|
||||
## Priority
|
||||
|
||||
Log flush has higher priority than artifact transfer. Artifact work must slow down when log spool pressure rises.
|
||||
|
||||
Log spool retry state is independent from artifact/file retry state. Acknowledged log batches may be removed even when artifact chunks are still pending, and artifact chunk acknowledgement must not alter log sequence state. Log ingest payloads carry bounded entries only and must not include artifact chunks, file bodies, host paths, raw credentials, or direct socket details.
|
||||
|
||||
The Run uploader flushes committed spool segments independently with bounded request contexts. A failed or partial acknowledgement leaves the segment pending for restart/retry; control heartbeat and job lifecycle polling do not wait for log or artifact flushes.
|
||||
|
||||
## Browser Channel
|
||||
|
||||
Browser live tail is a platform-owned SSE fan-out from durable ingest and cursor state. External log storage backends and optional game client bridge traffic remain separate channels. Artifact transfer uses its own lower-priority channel and must not be multiplexed through log ingest.
|
||||
@@ -0,0 +1,68 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type LogEntry struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Redacted bool `json:"redacted"`
|
||||
}
|
||||
|
||||
type LogBatchIngestRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Source string `json:"source"`
|
||||
LogSessionID string `json:"logSessionId,omitempty"`
|
||||
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
|
||||
FirstSeq uint64 `json:"firstSeq"`
|
||||
LastSeq uint64 `json:"lastSeq"`
|
||||
Compression string `json:"compression"`
|
||||
Checksum string `json:"checksum"`
|
||||
Entries []LogEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type LogBatchIngestResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
AcceptedFrom uint64 `json:"acceptedFrom"`
|
||||
AcceptedTo uint64 `json:"acceptedTo"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
RetryAfterSec int `json:"retryAfterSec,omitempty"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type LogStreamCursorRequest struct {
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
AfterSeq uint64 `json:"afterSeq"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type LogStreamCursorResponse struct {
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
Entries []LogEntry `json:"entries"`
|
||||
NextSeq uint64 `json:"nextSeq"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
}
|
||||
|
||||
type RunLogStreamProgressRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
}
|
||||
|
||||
type RunLogStreamProgressResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type MetricSample struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Online bool `json:"online"`
|
||||
PlayerCount *int `json:"playerCount,omitempty"`
|
||||
MaxPlayers *int `json:"maxPlayers,omitempty"`
|
||||
TPS *float64 `json:"tps,omitempty"`
|
||||
LatencyMS *float64 `json:"latencyMs,omitempty"`
|
||||
CPUPercent *float64 `json:"cpuPercent,omitempty"`
|
||||
MemoryPercent *float64 `json:"memoryPercent,omitempty"`
|
||||
DiskPercent *float64 `json:"diskPercent,omitempty"`
|
||||
Source string `json:"source"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
type MetricBatchIngestRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Samples []MetricSample `json:"samples"`
|
||||
}
|
||||
|
||||
type MetricBatchIngestResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
LatestAt time.Time `json:"latestAt"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Protected request execution
|
||||
|
||||
Protected SQL, RCON, and management-program work uses the job channel for its
|
||||
lease and a separate one-time input route for its approved text:
|
||||
|
||||
- capabilities: `remote.run.protected.sql`, `remote.run.protected.rcon`, and
|
||||
`remote.run.program.command`;
|
||||
- input route: `POST /api/v1/run/jobs/protected-request-input`;
|
||||
- assignment input ref: `input://protected-request/<job-id>`;
|
||||
- adapter kinds: `protected-sql`, `protected-rcon`, and `protected-program`.
|
||||
|
||||
The signed input request contains the Run endpoint/session, job ID, lease,
|
||||
attempt, and fencing token. Platform returns only the matching job/server/Run
|
||||
identity, fencing token, explicit authorization, approved/unexpired state,
|
||||
claimed queue state, protected request kind,
|
||||
logical transport/target keys, and bounded request text. Neither direction may
|
||||
carry a DSN, database path, password, socket, host path, shell, or raw
|
||||
connection.
|
||||
|
||||
Run validates all assignment and response bindings again immediately before
|
||||
dispatch. A protected request must have exactly one attempt, a non-zero fencing
|
||||
token, explicit authorization, an `approved` state, a `claimed` queue state, a
|
||||
future expiry, a capability-kind match, and exact transport/target identity
|
||||
matches. The request text is never written to the job journal or terminal
|
||||
result.
|
||||
|
||||
Transport implementations are registered locally by `(kind, transportKey)` and
|
||||
resolve any private connection configuration inside Run. A management-program
|
||||
handler is an application protocol handler, not a host process or OS shell.
|
||||
Unknown transport operations, request formats, or fields return the terminal
|
||||
safe result `protected_request_unknown`; other transport failures use bounded
|
||||
diagnostics without forwarding handler errors or response bodies. These errors
|
||||
affect only the current request.
|
||||
|
||||
Management-program stdout and stderr are bounded, redacted, and sent to the
|
||||
durable log channel with source `management-program` and streams
|
||||
`management-program.stdout` / `management-program.stderr`. They are not file
|
||||
execution logs and are never embedded in job result content.
|
||||
Reference in New Issue
Block a user