Complete platform management workflows
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}, " ")
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user