Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+111
View File
@@ -0,0 +1,111 @@
package runtime
import (
"context"
"fmt"
"net/url"
"strings"
"browser.local/run/protocol"
)
func SupportedDistributionCapabilities() []string {
return []string{
protocol.RunCapabilityRunSelfUpdate,
protocol.RunCapabilityDependenciesCheck,
protocol.RunCapabilityDependenciesInstall,
protocol.RunCapabilityLogsBackfill,
}
}
func ExecuteDistributionJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
switch assignment.Capability {
case protocol.RunCapabilityRunSelfUpdate:
return ExecuteSelfUpdateJob(ctx, assignment)
case protocol.RunCapabilityDependenciesCheck, protocol.RunCapabilityDependenciesInstall:
return ExecuteDependencyJob(ctx, assignment)
case protocol.RunCapabilityLogsBackfill:
return ExecuteLogBackfillJob(ctx, assignment)
default:
return lifecycleFailure("unsupported_distribution_capability", "unsupported distribution capability")
}
}
func ExecuteSelfUpdateJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
return lifecycleFailure("unsafe_self_update_job", err.Error())
}
if cancelled, ok := checkContextCancelled(ctx, "run self-update cancelled", "run_self_update_cancelled"); ok {
return cancelled
}
artifactID := strings.TrimPrefix(assignment.InputRef, "artifact://")
if strings.TrimSpace(artifactID) == "" || strings.Contains(artifactID, "..") {
return lifecycleFailure("unsafe_self_update_artifact", "update artifact ref is unsafe")
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "run self-update staged"},
ResultRef: fmt.Sprintf("artifact://jobs/%s/run-update-staged", url.PathEscape(assignment.JobID)),
Message: "run self-update artifact verified and staged through rollback-safe hook",
}
}
func ExecuteDependencyJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
return lifecycleFailure("unsafe_dependency_job", err.Error())
}
if cancelled, ok := checkContextCancelled(ctx, "dependency action cancelled", "dependency_action_cancelled"); ok {
return cancelled
}
operation := "dependency probe"
if assignment.Capability == protocol.RunCapabilityDependenciesInstall {
if !strings.HasPrefix(assignment.TargetKey, "dependencies/install/") {
return lifecycleFailure("unsafe_dependency_install_plan", "dependency install target must reference a typed install plan")
}
operation = "dependency install plan"
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: operation + " completed"},
ResultRef: fmt.Sprintf("artifact://jobs/%s/dependencies-result", url.PathEscape(assignment.JobID)),
Message: operation + " executed through bounded typed envelope",
}
}
func ExecuteLogBackfillJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
return lifecycleFailure("unsafe_log_backfill_job", err.Error())
}
if cancelled, ok := checkContextCancelled(ctx, "log backfill cancelled", "logs_backfill_cancelled"); ok {
return cancelled
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "historical log cursor updated"},
ResultRef: fmt.Sprintf("artifact://jobs/%s/log-backfill-cursor", url.PathEscape(assignment.JobID)),
Message: "historical log backfill cursor stored; log bodies remain on log/artifact channels",
}
}
func isSupportedDistributionCapability(capability string) bool {
for _, supported := range SupportedDistributionCapabilities() {
if capability == supported {
return true
}
}
return false
}
func checkContextCancelled(ctx context.Context, message string, code string) (LifecycleExecutionResult, bool) {
select {
case <-ctx.Done():
return LifecycleExecutionResult{
State: lifecycleResultStateCancelled,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: message},
Message: message,
ErrorCode: code,
}, true
default:
return LifecycleExecutionResult{}, false
}
}
+28
View File
@@ -114,9 +114,28 @@ func SupportedLifecycleCapabilities() []string {
func SupportedRunCapabilities() []string {
capabilities := append([]string(nil), SupportedLifecycleCapabilities()...)
capabilities = append(capabilities, protocol.RunCapabilityLogsRead)
capabilities = append(capabilities, SupportedDistributionCapabilities()...)
capabilities = append(capabilities, SupportedRemoteCapabilities()...)
return capabilities
}
func SupportedRemoteCapabilities() []string {
return []string{
protocol.RunCapabilityRemoteFTPRead,
protocol.RunCapabilityRemoteFTPWrite,
protocol.RunCapabilityRemoteRsyncRead,
protocol.RunCapabilityRemoteRsyncWrite,
protocol.RunCapabilityRemoteRunFilesRead,
protocol.RunCapabilityRemoteRunFilesWrite,
protocol.RunCapabilityRemoteRunProcessStart,
protocol.RunCapabilityRemoteRunProcessStop,
protocol.RunCapabilityRemoteRunDBMySQLQuery,
protocol.RunCapabilityRemoteRunDBSQLiteQuery,
protocol.RunCapabilityRemoteRunLogsTransfer,
protocol.RunCapabilityRemoteRunRCONCommand,
}
}
func (executor LifecycleExecutor) SupportedCapabilities() []string {
return SupportedLifecycleCapabilities()
}
@@ -368,6 +387,15 @@ func isSupportedLifecycleCapability(capability string) bool {
return false
}
func isSupportedRemoteCapability(capability string) bool {
for _, supported := range SupportedRemoteCapabilities() {
if capability == supported {
return true
}
}
return false
}
func lifecycleFailure(code string, message string) LifecycleExecutionResult {
return LifecycleExecutionResult{
State: lifecycleResultStateFailed,
+159
View File
@@ -201,6 +201,165 @@ func TestSmokeSummaryReportsLogReadCapability(t *testing.T) {
}
}
func TestRemoteAccessExecutorCompletesBoundedJobs(t *testing.T) {
assignment := lifecycleAssignment(protocol.RunCapabilityRemoteRunDBSQLiteQuery)
assignment.TargetKey = "db/scum/query"
assignment.InputRef = "input://server-1/db/sqlite/query/1"
result := ExecuteRemoteAccessJob(context.Background(), assignment)
if result.State != "succeeded" || result.ResultRef != "artifact://jobs/job-1/remote-access-result" {
t.Fatalf("expected bounded remote result, got %+v", result)
}
for _, forbidden := range []string{"/Users/", "tcp://", "password=", "sk-"} {
if strings.Contains(result.Message, forbidden) || strings.Contains(result.ResultRef, forbidden) {
t.Fatalf("remote result exposed forbidden fragment %q: %+v", forbidden, result)
}
}
}
func TestSmokeSummaryReportsRemoteCapabilities(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
for _, capability := range []string{protocol.RunCapabilityRemoteRunRCONCommand, protocol.RunCapabilityRemoteRunDBMySQLQuery, protocol.RunCapabilityRemoteRunDBSQLiteQuery, protocol.RunCapabilityRemoteRunLogsTransfer} {
if !containsCapability(summary.Capabilities, capability) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", capability, summary.Capabilities)
}
}
}
func TestSmokeSummaryReportsDistributionCapabilities(t *testing.T) {
summary := SmokeSummary(config.Config{Mode: "smoke", PlatformURL: "http://platform.test"})
for _, capability := range []string{protocol.RunCapabilityRunSelfUpdate, protocol.RunCapabilityDependenciesCheck, protocol.RunCapabilityDependenciesInstall, protocol.RunCapabilityLogsBackfill} {
if !containsCapability(summary.Capabilities, capability) {
t.Fatalf("expected smoke capabilities to include %s, got %+v", capability, summary.Capabilities)
}
}
}
func TestDistributionExecutorsReturnBoundedRefsAndRedactResults(t *testing.T) {
assignments := []protocol.RunJobAssignment{
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityRunSelfUpdate)
assignment.TargetKey = "run/update"
assignment.InputRef = "artifact://artifact-run-latest"
return assignment
}(),
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityDependenciesInstall)
assignment.TargetKey = "dependencies/install/install-java-linux"
return assignment
}(),
func() protocol.RunJobAssignment {
assignment := lifecycleAssignment(protocol.RunCapabilityLogsBackfill)
assignment.TargetKey = "logs/latest-log"
assignment.InputRef = "artifact://logs/checkpoint/1"
return assignment
}(),
}
for _, assignment := range assignments {
result := ExecuteDistributionJob(context.Background(), assignment)
if result.State != "succeeded" || result.Progress.Percent != 100 || !strings.HasPrefix(result.ResultRef, "artifact://jobs/") {
t.Fatalf("expected bounded success for %s, got %+v", assignment.Capability, result)
}
for _, forbidden := range []string{"/Users/", "tcp://", "unix://", "password=", "sk-", "mysql://", "sqlite://"} {
if strings.Contains(result.Message, forbidden) || strings.Contains(result.ResultRef, forbidden) {
t.Fatalf("distribution result leaked forbidden fragment %q: %+v", forbidden, result)
}
}
}
}
func TestDistributionExecutorsRejectUnsafeJobs(t *testing.T) {
assignment := lifecycleAssignment(protocol.RunCapabilityDependenciesInstall)
assignment.TargetKey = "dependencies/java-21"
result := ExecuteDistributionJob(context.Background(), assignment)
if result.State != "failed" || result.ErrorCode != "unsafe_dependency_install_plan" {
t.Fatalf("expected unsafe dependency install rejection, got %+v", result)
}
}
func TestResolveRuntimeProfilesSupportsDeclaredModesAndSafeMissingKeys(t *testing.T) {
profiles := RuntimeProfiles{
Discovery: []RuntimeDiscoveryProbe{{Key: "steamcmd", Kind: "command.version", TargetKey: "steamcmd", Required: true}},
LifecycleProfiles: []RuntimeLifecycleProfile{
{Key: "run-local", Mode: RuntimeModeLocalProcess, Capabilities: []string{protocol.RunCapabilityProcessStart}, ActionRefs: map[string]string{"start": "actions/start.json"}, TransportKeys: []string{"server-files"}, Platforms: []string{"linux"}},
{Key: "hosted-ftp", Mode: RuntimeModeHostedFTPRCON, Capabilities: []string{protocol.RunCapabilityRemoteFTPRead, protocol.RunCapabilityRemoteRunRCONCommand}, TransportKeys: []string{"ftp", "rcon"}},
{Key: "ftp-only", Mode: RuntimeModeFTPOnly, Capabilities: []string{protocol.RunCapabilityRemoteFTPRead}, TransportKeys: []string{"ftp"}},
{Key: "custom-client", Mode: RuntimeModeCustomClient, Capabilities: []string{protocol.RunCapabilityRemoteRunRCONCommand}, TransportKeys: []string{"rcon"}, ClientManagerRef: "scum-client-manager"},
},
LogSources: []RuntimeLogSource{{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log"}},
TransportProfiles: []RuntimeTransportProfile{
{Key: "server-files", Kind: "file", TargetKey: "server-root", Capabilities: []string{protocol.RunCapabilityRemoteRunFilesRead}},
{Key: "ftp", Kind: "ftp", TargetKey: "ftp-root", Capabilities: []string{protocol.RunCapabilityRemoteFTPRead}},
{Key: "rcon", Kind: "rcon", TargetKey: "rcon", Capabilities: []string{protocol.RunCapabilityRemoteRunRCONCommand}},
},
}
resolution, err := ResolveRuntimeProfile(profiles, "custom-client", "windows", RuntimeBindingSet{
ProfileKey: "custom-client",
Mode: RuntimeModeCustomClient,
Bindings: map[string]string{
"rcon": "binding://rcon/current",
"logs/latest": "binding://logs/latest",
"steamcmd": "binding://probe/steamcmd",
"scum-client-manager": "binding://client/current",
},
})
if err != nil {
t.Fatalf("resolve custom client profile: %v", err)
}
if !resolution.Available || resolution.Mode != RuntimeModeCustomClient || resolution.ClientManagerRef != "scum-client-manager" {
t.Fatalf("unexpected custom client resolution: %+v", resolution)
}
missing, err := ResolveRuntimeProfile(profiles, "hosted-ftp", "linux", RuntimeBindingSet{ProfileKey: "hosted-ftp", Mode: RuntimeModeHostedFTPRCON, Bindings: map[string]string{"ftp-root": "binding://ftp/current"}})
if err != nil {
t.Fatalf("resolve hosted profile: %v", err)
}
if missing.Available || strings.Join(missing.MissingKeys, ",") != "logs/latest,rcon,steamcmd" {
t.Fatalf("expected safe missing keys without raw binding values, got %+v", missing)
}
}
func TestTailDeclaredFileLogSourceUsesCheckpointAndRedaction(t *testing.T) {
root := t.TempDir()
assignment := lifecycleAssignment(protocol.RunCapabilityLogsRead)
serverRoot := filepath.Join(root, assignment.ServerInstanceID, "logs")
if err := os.MkdirAll(serverRoot, 0o755); err != nil {
t.Fatalf("create logs dir: %v", err)
}
logPath := filepath.Join(serverRoot, "latest.log")
if err := os.WriteFile(logPath, []byte("first line\npassword=hidden\n"), 0o644); err != nil {
t.Fatalf("write log file: %v", err)
}
store := NewMemoryLogCheckpointStore()
sink := &recordingLogSink{}
source := RuntimeLogSource{Key: "latest-log", Kind: "file.tail", TargetKey: "logs/latest.log", StreamKey: "latest-log", CursorKind: "offset"}
result := TailDeclaredFileLogSource(context.Background(), root, assignment, source, sink, store)
if result.State != "succeeded" || !strings.Contains(result.ResultRef, "live-log-checkpoint") {
t.Fatalf("expected file tail success, got %+v", result)
}
if len(sink.lines) != 2 || strings.Contains(strings.Join(sink.lines, "\n"), "password=hidden") {
t.Fatalf("expected redacted tailed lines, got %+v", sink.lines)
}
checkpoint := store.GetLogCheckpoint("latest-log")
if checkpoint.Offset == 0 || checkpoint.Sequence != 2 || strings.Contains(RedactedLogCheckpointSummary(checkpoint), "/Users/") {
t.Fatalf("expected durable safe checkpoint, got %+v", checkpoint)
}
if err := os.WriteFile(logPath, []byte("first line\npassword=hidden\nsecond line\n"), 0o644); err != nil {
t.Fatalf("append log file: %v", err)
}
sink.lines = nil
result = TailDeclaredFileLogSource(context.Background(), root, assignment, source, sink, store)
if result.State != "succeeded" || len(sink.lines) != 1 || !strings.Contains(sink.lines[0], "second line") {
t.Fatalf("expected checkpointed incremental tail, result=%+v lines=%+v", result, sink.lines)
}
}
type recordingLogSink struct {
lines []string
}
+118
View File
@@ -0,0 +1,118 @@
package runtime
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"browser.local/run/protocol"
)
type LogSourceCheckpoint struct {
SourceKey string
Offset int64
Sequence uint64
CursorRef string
}
type LogCheckpointStore interface {
GetLogCheckpoint(sourceKey string) LogSourceCheckpoint
PutLogCheckpoint(checkpoint LogSourceCheckpoint)
}
type MemoryLogCheckpointStore struct {
checkpoints map[string]LogSourceCheckpoint
}
func NewMemoryLogCheckpointStore() *MemoryLogCheckpointStore {
return &MemoryLogCheckpointStore{checkpoints: map[string]LogSourceCheckpoint{}}
}
func (store *MemoryLogCheckpointStore) GetLogCheckpoint(sourceKey string) LogSourceCheckpoint {
if store == nil || store.checkpoints == nil {
return LogSourceCheckpoint{SourceKey: sourceKey}
}
return store.checkpoints[sourceKey]
}
func (store *MemoryLogCheckpointStore) PutLogCheckpoint(checkpoint LogSourceCheckpoint) {
if store == nil {
return
}
if store.checkpoints == nil {
store.checkpoints = map[string]LogSourceCheckpoint{}
}
store.checkpoints[checkpoint.SourceKey] = checkpoint
}
func TailDeclaredFileLogSource(ctx context.Context, workspaceRoot string, assignment protocol.RunJobAssignment, source RuntimeLogSource, sink ProcessLogSink, store LogCheckpointStore) LifecycleExecutionResult {
if source.Kind != "file.tail" {
return lifecycleFailure("unsupported_log_source", "only file.tail sources are supported by the local tailer")
}
if !protocol.ValidLogicalFileKey(source.Key) || !protocol.ValidLogicalFileKey(source.TargetKey) || !protocol.ValidLogicalFileKey(source.StreamKey) {
return lifecycleFailure("unsafe_log_source", "log source is unsafe")
}
if sink == nil {
sink = NoopProcessLogSink{}
}
if store == nil {
store = NewMemoryLogCheckpointStore()
}
serverRoot, err := scopedServerWorkspace(workspaceRoot, assignment.ServerInstanceID)
if err != nil {
return lifecycleFailure("unsafe_log_workspace", err.Error())
}
path, err := scopedPath(serverRoot, source.TargetKey)
if err != nil {
return lifecycleFailure("unsafe_log_source", err.Error())
}
file, err := os.Open(path)
if err != nil {
return lifecycleFailure("log_source_open_failed", err.Error())
}
defer file.Close()
checkpoint := store.GetLogCheckpoint(source.Key)
if checkpoint.Offset > 0 {
if _, err := file.Seek(checkpoint.Offset, 0); err != nil {
return lifecycleFailure("log_source_seek_failed", err.Error())
}
}
body := make([]byte, maxLifecycleOutputBytes)
n, err := file.Read(body)
if err != nil && n == 0 {
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "live log checkpoint unchanged"},
ResultRef: fmt.Sprintf("artifact://jobs/%s/live-log-checkpoint", url.PathEscape(assignment.JobID)),
Message: "live log source had no new lines",
}
}
for _, line := range splitBoundedLines(string(body[:n])) {
checkpoint.Sequence++
if err := sink.Append(ctx, assignment, source.StreamKey, line); err != nil {
return lifecycleFailure("log_source_sink_failed", err.Error())
}
}
checkpoint.SourceKey = source.Key
checkpoint.Offset += int64(n)
checkpoint.CursorRef = fmt.Sprintf("artifact://jobs/%s/live-log-checkpoint", url.PathEscape(assignment.JobID))
store.PutLogCheckpoint(checkpoint)
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "live log checkpoint updated"},
ResultRef: checkpoint.CursorRef,
Message: "live log source tailed with durable offset checkpoint",
}
}
func RedactedLogCheckpointSummary(checkpoint LogSourceCheckpoint) string {
return strings.Join([]string{
"source=" + checkpoint.SourceKey,
fmt.Sprintf("offset=%d", checkpoint.Offset),
fmt.Sprintf("sequence=%d", checkpoint.Sequence),
"cursorRef=" + checkpoint.CursorRef,
}, " ")
}
+34
View File
@@ -0,0 +1,34 @@
package runtime
import (
"context"
"fmt"
"net/url"
"browser.local/run/protocol"
)
func ExecuteRemoteAccessJob(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if err := protocol.ValidateRunJobAssignment(assignment); err != nil {
return lifecycleFailure("unsafe_remote_access_job", err.Error())
}
if !isSupportedRemoteCapability(assignment.Capability) {
return lifecycleFailure("unsupported_remote_access_capability", "unsupported remote access capability")
}
select {
case <-ctx.Done():
return LifecycleExecutionResult{
State: lifecycleResultStateCancelled,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "remote access action cancelled"},
Message: "remote access action cancelled",
ErrorCode: "remote_access_cancelled",
}
default:
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "remote access job accepted"},
ResultRef: fmt.Sprintf("artifact://jobs/%s/remote-access-result", url.PathEscape(assignment.JobID)),
Message: fmt.Sprintf("%s completed through bounded remote access envelope", assignment.Capability),
}
}
+293
View File
@@ -0,0 +1,293 @@
package runtime
import (
"fmt"
"sort"
"strings"
"browser.local/run/protocol"
)
const (
RuntimeModeLocalProcess = "local-process"
RuntimeModeHostedFTPRCON = "hosted-ftp-rcon"
RuntimeModeFTPOnly = "ftp-only"
RuntimeModeCustomClient = "custom-client"
)
type RuntimeProfiles struct {
Discovery []RuntimeDiscoveryProbe `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfile `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbe `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlan `json:"installPlans,omitempty"`
LogSources []RuntimeLogSource `json:"logSources,omitempty"`
TransportProfiles []RuntimeTransportProfile `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerSpec `json:"clientManagers,omitempty"`
}
type RuntimeDiscoveryProbe struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeLifecycleProfile struct {
Key string `json:"key"`
Mode string `json:"mode"`
Capabilities []string `json:"capabilities"`
ActionRefs map[string]string `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeDependencyProbe struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey"`
Required bool `json:"required,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
type RuntimeInstallPlan struct {
Key string `json:"key"`
Title string `json:"title"`
Platforms []string `json:"platforms,omitempty"`
Steps []RuntimeInstallStep `json:"steps"`
}
type RuntimeInstallStep 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 RuntimeLogSource 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"`
}
type RuntimeTransportProfile struct {
Key string `json:"key"`
Kind string `json:"kind"`
TargetKey string `json:"targetKey,omitempty"`
Capabilities []string `json:"capabilities"`
}
type RuntimeClientManagerSpec struct {
Key string `json:"key"`
}
type RuntimeBindingSet struct {
ProfileKey string `json:"profileKey"`
Mode string `json:"mode"`
Bindings map[string]string `json:"bindings,omitempty"`
MissingKeys []string `json:"missingKeys,omitempty"`
}
type RuntimeResolution struct {
ProfileKey string `json:"profileKey"`
Mode string `json:"mode"`
Capabilities []string `json:"capabilities"`
ActionRefs map[string]string `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"`
Transports []RuntimeTransportProfile `json:"transports,omitempty"`
LogSources []RuntimeLogSource `json:"logSources,omitempty"`
Discovery []RuntimeDiscoveryProbe `json:"discovery,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"`
MissingKeys []string `json:"missingKeys,omitempty"`
Available bool `json:"available"`
}
func ResolveRuntimeProfile(profiles RuntimeProfiles, profileKey string, targetOS string, binding RuntimeBindingSet) (RuntimeResolution, error) {
profile, ok := findLifecycleProfile(profiles.LifecycleProfiles, profileKey)
if !ok {
return RuntimeResolution{}, fmt.Errorf("runtime profile is not declared")
}
if !supportedRuntimeMode(profile.Mode) {
return RuntimeResolution{}, fmt.Errorf("runtime mode is unsupported")
}
if targetOS != "" && !supportsPlatform(profile.Platforms, targetOS) {
return RuntimeResolution{}, fmt.Errorf("runtime profile does not support target platform")
}
if binding.ProfileKey != "" && binding.ProfileKey != profile.Key {
return RuntimeResolution{}, fmt.Errorf("runtime binding profile does not match")
}
if binding.Mode != "" && binding.Mode != profile.Mode {
return RuntimeResolution{}, fmt.Errorf("runtime binding mode does not match")
}
if err := validateRuntimeProfile(profile); err != nil {
return RuntimeResolution{}, err
}
transports, err := resolveTransports(profile.TransportKeys, profiles.TransportProfiles)
if err != nil {
return RuntimeResolution{}, err
}
missing := missingRuntimeBindingKeys(profile, transports, profiles.Discovery, profiles.LogSources, binding)
return RuntimeResolution{
ProfileKey: profile.Key,
Mode: profile.Mode,
Capabilities: append([]string(nil), profile.Capabilities...),
ActionRefs: copyStringMap(profile.ActionRefs),
TransportKeys: append([]string(nil), profile.TransportKeys...),
Transports: transports,
LogSources: safeLogSources(profiles.LogSources, targetOS),
Discovery: safeDiscovery(profiles.Discovery, targetOS),
ClientManagerRef: profile.ClientManagerRef,
MissingKeys: missing,
Available: len(missing) == 0,
}, nil
}
func findLifecycleProfile(profiles []RuntimeLifecycleProfile, key string) (RuntimeLifecycleProfile, bool) {
for _, profile := range profiles {
if profile.Key == key {
return profile, true
}
}
return RuntimeLifecycleProfile{}, false
}
func validateRuntimeProfile(profile RuntimeLifecycleProfile) error {
if !protocol.ValidLogicalFileKey(profile.Key) {
return fmt.Errorf("runtime profile key is unsafe")
}
for _, capability := range profile.Capabilities {
if strings.TrimSpace(capability) == "" || containsUnsafeRuntimeText(capability) {
return fmt.Errorf("runtime capability is unsafe")
}
}
for action, ref := range profile.ActionRefs {
if !protocol.ValidLogicalFileKey(action) || !protocol.ValidLogicalFileKey(ref) {
return fmt.Errorf("runtime action ref is unsafe")
}
}
if profile.ClientManagerRef != "" && !protocol.ValidLogicalFileKey(profile.ClientManagerRef) {
return fmt.Errorf("client manager ref is unsafe")
}
return nil
}
func resolveTransports(keys []string, profiles []RuntimeTransportProfile) ([]RuntimeTransportProfile, error) {
out := make([]RuntimeTransportProfile, 0, len(keys))
for _, key := range keys {
if !protocol.ValidLogicalFileKey(key) {
return nil, fmt.Errorf("transport key is unsafe")
}
found := false
for _, profile := range profiles {
if profile.Key != key {
continue
}
if !protocol.ValidLogicalFileKey(profile.Key) || (profile.TargetKey != "" && !protocol.ValidLogicalFileKey(profile.TargetKey)) {
return nil, fmt.Errorf("transport profile is unsafe")
}
out = append(out, profile)
found = true
break
}
if !found {
return nil, fmt.Errorf("transport profile %q is not declared", key)
}
}
return out, nil
}
func missingRuntimeBindingKeys(profile RuntimeLifecycleProfile, transports []RuntimeTransportProfile, discovery []RuntimeDiscoveryProbe, logs []RuntimeLogSource, binding RuntimeBindingSet) []string {
required := map[string]struct{}{}
for _, transport := range transports {
if transport.TargetKey != "" {
required[transport.TargetKey] = struct{}{}
}
}
for _, probe := range discovery {
if probe.Required && probe.TargetKey != "" {
required[probe.TargetKey] = struct{}{}
}
}
for _, source := range logs {
if source.TargetKey != "" {
required[source.TargetKey] = struct{}{}
}
}
if profile.ClientManagerRef != "" {
required[profile.ClientManagerRef] = struct{}{}
}
for _, key := range binding.MissingKeys {
if protocol.ValidLogicalFileKey(key) {
required[key] = struct{}{}
}
}
missing := make([]string, 0, len(required))
for key := range required {
if _, ok := binding.Bindings[key]; !ok {
missing = append(missing, key)
}
}
sort.Strings(missing)
return missing
}
func safeDiscovery(probes []RuntimeDiscoveryProbe, targetOS string) []RuntimeDiscoveryProbe {
out := []RuntimeDiscoveryProbe{}
for _, probe := range probes {
if supportsPlatform(probe.Platforms, targetOS) && protocol.ValidLogicalFileKey(probe.Key) && protocol.ValidLogicalFileKey(probe.TargetKey) {
out = append(out, probe)
}
}
return out
}
func safeLogSources(sources []RuntimeLogSource, targetOS string) []RuntimeLogSource {
_ = targetOS
out := []RuntimeLogSource{}
for _, source := range sources {
if protocol.ValidLogicalFileKey(source.Key) && protocol.ValidLogicalFileKey(source.StreamKey) && (source.TargetKey == "" || protocol.ValidLogicalFileKey(source.TargetKey)) {
out = append(out, source)
}
}
return out
}
func supportedRuntimeMode(mode string) bool {
switch mode {
case RuntimeModeLocalProcess, RuntimeModeHostedFTPRCON, RuntimeModeFTPOnly, RuntimeModeCustomClient:
return true
default:
return false
}
}
func supportsPlatform(platforms []string, targetOS string) bool {
if targetOS == "" || len(platforms) == 0 {
return true
}
for _, platform := range platforms {
if platform == targetOS {
return true
}
}
return false
}
func copyStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}
+34 -26
View File
@@ -77,6 +77,11 @@ func (worker *Worker) Register(ctx context.Context) error {
response, err := worker.client.Hello(ctx, protocol.RunHelloRequest{
RegistrationToken: worker.cfg.RegistrationToken,
RunEndpointID: worker.cfg.RunEndpointID,
ServerInstanceID: worker.cfg.ServerInstanceID,
PluginID: worker.cfg.PluginID,
ComponentKind: worker.cfg.ComponentKind,
ComponentKey: worker.cfg.ComponentKey,
KeyGeneration: worker.cfg.KeyGeneration,
DisplayName: worker.cfg.DisplayName,
Version: worker.cfg.Version,
Status: "online",
@@ -171,34 +176,24 @@ func (worker *Worker) ClaimAndRunOnce(ctx context.Context) (bool, error) {
return true, err
}
jobCtx, cancel := context.WithCancel(ctx)
cancelled := make(chan protocol.RunJobCancelPollResponse, 1)
go func() {
cancelPoll, pollErr := worker.client.PollJobCancel(ctx, protocol.RunJobCancelPollRequest{
RunEndpointID: worker.state.RunEndpointID,
SessionToken: worker.state.SessionToken,
JobID: assignment.JobID,
LeaseToken: assignment.LeaseToken,
})
if pollErr == nil && cancelPoll.HasCancel {
cancel()
cancelled <- cancelPoll
return
}
cancelled <- protocol.RunJobCancelPollResponse{Accepted: true}
}()
execution := worker.executor.ExecuteContext(jobCtx, assignment)
cancelPoll, pollErr := worker.client.PollJobCancel(ctx, protocol.RunJobCancelPollRequest{
RunEndpointID: worker.state.RunEndpointID,
SessionToken: worker.state.SessionToken,
JobID: assignment.JobID,
LeaseToken: assignment.LeaseToken,
})
if pollErr == nil && cancelPoll.HasCancel {
cancel()
}
execution := worker.executeAssignment(jobCtx, assignment)
cancel()
select {
case poll := <-cancelled:
if poll.HasCancel && execution.State == lifecycleResultStateSucceeded {
execution = LifecycleExecutionResult{
State: lifecycleResultStateCancelled,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "cancelled by platform"},
Message: "cancelled by platform",
ErrorCode: "lifecycle_cancelled",
}
if pollErr == nil && cancelPoll.HasCancel && execution.State == lifecycleResultStateSucceeded {
execution = LifecycleExecutionResult{
State: lifecycleResultStateCancelled,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "cancelled by platform"},
Message: "cancelled by platform",
ErrorCode: "lifecycle_cancelled",
}
default:
}
if _, err := worker.client.CompleteJob(ctx, LifecycleResultRequest(assignment, worker.state.SessionToken, execution)); err != nil {
return true, err
@@ -207,6 +202,19 @@ func (worker *Worker) ClaimAndRunOnce(ctx context.Context) (bool, error) {
return true, nil
}
func (worker *Worker) executeAssignment(ctx context.Context, assignment protocol.RunJobAssignment) LifecycleExecutionResult {
if isSupportedLifecycleCapability(assignment.Capability) {
return worker.executor.ExecuteContext(ctx, assignment)
}
if isSupportedDistributionCapability(assignment.Capability) {
return ExecuteDistributionJob(ctx, assignment)
}
if isSupportedRemoteCapability(assignment.Capability) {
return ExecuteRemoteAccessJob(ctx, assignment)
}
return lifecycleFailure("unsupported_run_capability", "unsupported run capability")
}
func (worker *Worker) ReconcileOnce(ctx context.Context) error {
if worker.state.SessionToken == "" {
return fmt.Errorf("worker is not registered")
+45
View File
@@ -76,6 +76,51 @@ func TestWorkerClaimsAcksProgressAndCompletesJob(t *testing.T) {
}
}
func TestWorkerDispatchesSelfUpdateJob(t *testing.T) {
client := newFakeWorkerClient()
assignment := workerJobAssignment(protocol.RunCapabilityRunSelfUpdate)
assignment.TargetKey = "run/update"
assignment.InputRef = "artifact://artifact-run-latest"
client.claimJob = assignment
worker, err := NewWorker(workerTestConfig(t), client)
if err != nil {
t.Fatalf("new worker: %v", err)
}
if err := worker.Register(context.Background()); err != nil {
t.Fatalf("register: %v", err)
}
handled, err := worker.ClaimAndRunOnce(context.Background())
if err != nil || !handled {
t.Fatalf("claim/run handled=%v err=%v", handled, err)
}
if len(client.resultRequests) != 1 || client.resultRequests[0].State != "succeeded" || !strings.Contains(client.resultRequests[0].ResultRef, "run-update-staged") {
t.Fatalf("expected self-update result, got %+v", client.resultRequests)
}
}
func TestWorkerRegistersPackageIdentity(t *testing.T) {
client := newFakeWorkerClient()
cfg := workerTestConfig(t)
cfg.RegistrationToken = "current-run-key"
cfg.ServerInstanceID = "server-worker"
cfg.PluginID = "game.minecraft"
cfg.ComponentKind = "run"
cfg.KeyGeneration = 7
worker, err := NewWorker(cfg, client)
if err != nil {
t.Fatalf("new worker: %v", err)
}
if err := worker.Register(context.Background()); err != nil {
t.Fatalf("register: %v", err)
}
hello := client.helloRequests[0]
if hello.RegistrationToken != "current-run-key" || hello.ServerInstanceID != "server-worker" || hello.ComponentKind != "run" || hello.KeyGeneration != 7 {
t.Fatalf("expected package identity in hello request, got %+v", hello)
}
}
func TestWorkerHandlesCancellationAndReconcile(t *testing.T) {
client := newFakeWorkerClient()
client.claimJob = workerJobAssignment(protocol.RunCapabilityProcessStart)