Complete platform management workflows
This commit is contained in:
@@ -45,6 +45,7 @@ Runtime configuration:
|
||||
- `RUN_MODE`: local mode, default `smoke`.
|
||||
- `RUN_PLATFORM_URL`: platform base URL, default `http://127.0.0.1:8080`.
|
||||
- `RUN_ENDPOINT_ID`, `RUN_DISPLAY_NAME`, `RUN_VERSION`, `RUN_REGISTRATION_TOKEN`: worker identity and registration metadata.
|
||||
- `RUN_PACKAGE_CONFIG`: optional path to a generated platform package config. When set, run validates the config, uses its `authKey` as the registration token, and sends server/component identity plus key generation during control hello.
|
||||
- `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`: scoped local server workspace and separate local log/artifact queues.
|
||||
- `RUN_MAX_JOBS`, `RUN_HEARTBEAT_INTERVAL_MS`, `RUN_POLL_INTERVAL_MS`, `RUN_RETRY_BACKOFF_MS`: worker capacity and scheduling controls.
|
||||
|
||||
@@ -59,8 +60,36 @@ go run ./cmd/run
|
||||
|
||||
Use `RUN_MODE=worker` when you want the executor to register, heartbeat, claim jobs, and execute lifecycle templates. Use `RUN_MODE=smoke` for a one-shot config summary.
|
||||
|
||||
Generated run and client-manager packages carry a secret-bearing JSON config created by platform. The config contains:
|
||||
|
||||
- component kind: `run` or `client-manager`.
|
||||
- server instance ID, plugin ID, optional run endpoint ID, optional client-manager profile key.
|
||||
- target OS/architecture, redacted `secret://runtime-keys/.../current` ref, key generation, and the raw current auth key needed by the remote executable.
|
||||
|
||||
The raw auth key is valid only while it matches the single current encrypted key stored in platform for that server/component. Resetting the run key or a client-manager key increments generation and makes older packages fail control hello authentication until the operator regenerates and redeploys the affected package. Local diagnostics and smoke summaries use fingerprints and secret refs, not raw keys.
|
||||
|
||||
In Docker, `RUN_PLATFORM_URL` must be `http://platform:8080` because `platform` is the compose service name. Locally, keep it as `http://127.0.0.1:8080`.
|
||||
|
||||
Current executable behavior includes smoke mode plus worker mode. Worker mode registers with platform, sends lightweight heartbeat metadata, claims lifecycle jobs, acknowledges leases, reports bounded progress, executes scoped `process.install`, `process.start`, and `process.stop` command templates inside per-server workspaces, polls cancellation, submits terminal results, and reconciles active jobs.
|
||||
|
||||
Lifecycle templates are JSON files addressed by logical keys under the server workspace. They resolve to direct executable/argument vectors, not shell strings. Absolute paths, parent traversal, raw credentials, direct sockets, shell launchers, unsafe environment keys, and unsafe output are rejected or redacted. Process stdout/stderr is written to the log spool, and lifecycle result metadata is queued through artifact hooks so control heartbeat and job result submission stay independent from log and artifact work.
|
||||
|
||||
## Runtime Profiles And Distribution Jobs
|
||||
|
||||
Run resolves plugin-declared runtime profiles using server runtime bindings supplied by platform. Supported modes are:
|
||||
|
||||
- `local-process`: run starts/stops the third-party server through scoped lifecycle action refs and tails stdout/stderr.
|
||||
- `hosted-ftp-rcon`: run exposes only declared FTP/log/RCON adapters for hosted servers that cannot be started locally.
|
||||
- `ftp-only`: run exposes declared FTP and log transfer surfaces without lifecycle or RCON control.
|
||||
- `custom-client`: run coordinates with a plugin-declared companion client manager using a separate component key and profile ref.
|
||||
|
||||
Profile resolution returns logical capabilities, transport keys, declared log sources, discovery probes, and missing binding keys. It must not return raw host paths, FTP credentials, SQL DSNs, RCON passwords, direct sockets, or component auth keys.
|
||||
|
||||
Worker mode now dispatches distribution capabilities in addition to lifecycle work:
|
||||
|
||||
- `run.self-update`: validates the update assignment, downloads by artifact ref, verifies checksum/signature hooks, stages the replacement, and reports rollback-safe status through a bounded result ref.
|
||||
- `dependencies.check`: executes a typed plugin-declared probe using logical target keys such as `dependencies/java-21`.
|
||||
- `dependencies.install`: executes only typed install plans addressed under `dependencies/install/...`; arbitrary shell snippets are rejected before execution.
|
||||
- `logs.backfill`: advances historical log cursors for declared sources and returns a cursor/result artifact ref instead of embedding large log bodies in job results.
|
||||
|
||||
Declared file log sources use a tailer with offset checkpoints and redaction before entries enter the durable log channel. FTP/rsync, SQL read, RCON command, and file transfer adapters are represented as bounded envelopes with scoped input or artifact refs. Long transfers remain lower priority than heartbeat, job ack/result, cancellation polling, reconcile, and log acknowledgement.
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if packageConfig, ok, err := config.LoadPackageConfigFromEnv(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid run package config: %v\n", err)
|
||||
os.Exit(1)
|
||||
} else if ok {
|
||||
cfg = config.ApplyPackageConfig(cfg, packageConfig)
|
||||
}
|
||||
client, err := api.NewPlatformClient(cfg.PlatformURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid platform URL: %v\n", err)
|
||||
|
||||
@@ -22,6 +22,12 @@ type Config struct {
|
||||
DisplayName string
|
||||
Version string
|
||||
RegistrationToken string
|
||||
ServerInstanceID string
|
||||
PluginID string
|
||||
ComponentKind string
|
||||
ComponentKey string
|
||||
KeyGeneration int
|
||||
SecretRef string
|
||||
WorkspaceRoot string
|
||||
SpoolRoot string
|
||||
MaxJobs int
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
PackageComponentRun = "run"
|
||||
PackageComponentClientManager = "client-manager"
|
||||
|
||||
PackageConfigEnv = "RUN_PACKAGE_CONFIG"
|
||||
)
|
||||
|
||||
type PackageConfig struct {
|
||||
Kind string `json:"kind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
AuthKey string `json:"authKey"`
|
||||
}
|
||||
|
||||
type PackageIdentity struct {
|
||||
Kind string `json:"kind"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId"`
|
||||
RunEndpointID string `json:"runEndpointId,omitempty"`
|
||||
ProfileKey string `json:"profileKey,omitempty"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
SecretRef string `json:"secretRef"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
KeyFingerprint string `json:"keyFingerprint"`
|
||||
}
|
||||
|
||||
type ComponentAuthResult struct {
|
||||
ServerInstanceID string
|
||||
Kind string
|
||||
ProfileKey string
|
||||
KeyGeneration int
|
||||
Allowed bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
func LoadPackageConfig(path string) (PackageConfig, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return PackageConfig{}, fmt.Errorf("read run package config: %w", err)
|
||||
}
|
||||
var cfg PackageConfig
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
return PackageConfig{}, fmt.Errorf("decode run package config: %w", err)
|
||||
}
|
||||
if err := ValidatePackageConfig(cfg); err != nil {
|
||||
return PackageConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadPackageConfigFromEnv() (PackageConfig, bool, error) {
|
||||
path := strings.TrimSpace(os.Getenv(PackageConfigEnv))
|
||||
if path == "" {
|
||||
return PackageConfig{}, false, nil
|
||||
}
|
||||
cfg, err := LoadPackageConfig(path)
|
||||
return cfg, true, err
|
||||
}
|
||||
|
||||
func ValidatePackageConfig(cfg PackageConfig) error {
|
||||
var violations []string
|
||||
if cfg.Kind != PackageComponentRun && cfg.Kind != PackageComponentClientManager {
|
||||
violations = append(violations, "kind is invalid")
|
||||
}
|
||||
if !safeIdentifier(cfg.ServerInstanceID) {
|
||||
violations = append(violations, "serverInstanceId is invalid")
|
||||
}
|
||||
if !safePluginID(cfg.PluginID) {
|
||||
violations = append(violations, "pluginId is invalid")
|
||||
}
|
||||
if cfg.RunEndpointID != "" && !safeIdentifier(cfg.RunEndpointID) {
|
||||
violations = append(violations, "runEndpointId is invalid")
|
||||
}
|
||||
if cfg.ProfileKey != "" && !safeLogicalKey(cfg.ProfileKey) {
|
||||
violations = append(violations, "profileKey is invalid")
|
||||
}
|
||||
if cfg.Kind == PackageComponentClientManager && cfg.ProfileKey == "" {
|
||||
violations = append(violations, "profileKey is required for client-manager packages")
|
||||
}
|
||||
if !safeRuntimeTarget(cfg.TargetOS, cfg.TargetArch) {
|
||||
violations = append(violations, "target platform is invalid")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.SecretRef, "secret://runtime-keys/") || containsUnsafeDiagnosticText(cfg.SecretRef) {
|
||||
violations = append(violations, "secretRef is invalid")
|
||||
}
|
||||
if cfg.KeyGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration must be positive")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AuthKey) == "" {
|
||||
violations = append(violations, "authKey is required")
|
||||
}
|
||||
if containsUnsafeDiagnosticText(cfg.AuthKey) {
|
||||
violations = append(violations, "authKey contains unsafe content")
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
return fmt.Errorf("invalid run package config: %s", strings.Join(violations, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyPackageConfig(base Config, pkg PackageConfig) Config {
|
||||
base.RegistrationToken = pkg.AuthKey
|
||||
base.ServerInstanceID = pkg.ServerInstanceID
|
||||
base.PluginID = pkg.PluginID
|
||||
base.ComponentKind = pkg.Kind
|
||||
base.ComponentKey = pkg.ProfileKey
|
||||
base.KeyGeneration = pkg.KeyGeneration
|
||||
base.SecretRef = pkg.SecretRef
|
||||
if pkg.RunEndpointID != "" {
|
||||
base.RunEndpointID = pkg.RunEndpointID
|
||||
}
|
||||
if base.DisplayName == "" || base.DisplayName == DefaultDisplayName {
|
||||
base.DisplayName = "Run " + pkg.ServerInstanceID
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (cfg PackageConfig) Identity() PackageIdentity {
|
||||
return PackageIdentity{
|
||||
Kind: cfg.Kind,
|
||||
ServerInstanceID: cfg.ServerInstanceID,
|
||||
PluginID: cfg.PluginID,
|
||||
RunEndpointID: cfg.RunEndpointID,
|
||||
ProfileKey: cfg.ProfileKey,
|
||||
TargetOS: cfg.TargetOS,
|
||||
TargetArch: cfg.TargetArch,
|
||||
SecretRef: cfg.SecretRef,
|
||||
KeyGeneration: cfg.KeyGeneration,
|
||||
KeyFingerprint: fingerprint(cfg.AuthKey),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg PackageConfig) RedactedDiagnostics() map[string]string {
|
||||
identity := cfg.Identity()
|
||||
return map[string]string{
|
||||
"kind": identity.Kind,
|
||||
"serverInstanceId": identity.ServerInstanceID,
|
||||
"pluginId": identity.PluginID,
|
||||
"runEndpointId": identity.RunEndpointID,
|
||||
"profileKey": identity.ProfileKey,
|
||||
"target": identity.TargetOS + "/" + identity.TargetArch,
|
||||
"secretRef": identity.SecretRef,
|
||||
"keyGeneration": fmt.Sprintf("%d", identity.KeyGeneration),
|
||||
"keyFingerprint": identity.KeyFingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
func AuthenticatePackageGeneration(pkg PackageConfig, auth ComponentAuthResult) error {
|
||||
if err := ValidatePackageConfig(pkg); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth.ServerInstanceID != pkg.ServerInstanceID || auth.Kind != pkg.Kind || auth.ProfileKey != pkg.ProfileKey {
|
||||
return fmt.Errorf("component authentication scope does not match package")
|
||||
}
|
||||
if !auth.Allowed {
|
||||
return fmt.Errorf("component authentication rejected: %s", redactedReason(auth.Reason))
|
||||
}
|
||||
if auth.KeyGeneration != pkg.KeyGeneration {
|
||||
return fmt.Errorf("component key generation is no longer current")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func safeIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 120 || containsUnsafeDiagnosticText(value) {
|
||||
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
|
||||
}
|
||||
|
||||
func safePluginID(value string) bool {
|
||||
return strings.HasPrefix(value, "game.") && safeIdentifier(value)
|
||||
}
|
||||
|
||||
func safeLogicalKey(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 120 || strings.HasPrefix(value, "/") || strings.Contains(value, "..") || strings.Contains(value, `\`) || containsUnsafeDiagnosticText(value) {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRuntimeTarget(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 containsUnsafeDiagnosticText(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"/users/", "/.ssh/", "password=", "apikey", "api_key", "bearer ", "sk-", "unix://", "tcp://", "mysql://", "sqlite://"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactedReason(value string) string {
|
||||
if containsUnsafeDiagnosticText(value) {
|
||||
return "[redacted]"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPackageConfigAppliesServerScopedIdentity(t *testing.T) {
|
||||
path := writePackageConfig(t, PackageConfig{
|
||||
Kind: PackageComponentRun,
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.minecraft",
|
||||
RunEndpointID: "run-server-1",
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||
KeyGeneration: 3,
|
||||
AuthKey: "opaque-runtime-key",
|
||||
})
|
||||
|
||||
pkg, err := LoadPackageConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load package config: %v", err)
|
||||
}
|
||||
cfg := ApplyPackageConfig(Config{RunEndpointID: DefaultEndpointID, DisplayName: DefaultDisplayName}, pkg)
|
||||
if cfg.RegistrationToken != "opaque-runtime-key" || cfg.RunEndpointID != "run-server-1" || cfg.ServerInstanceID != "server-1" || cfg.KeyGeneration != 3 {
|
||||
t.Fatalf("expected package identity to be applied, got %+v", cfg)
|
||||
}
|
||||
diagnostics := pkg.RedactedDiagnostics()
|
||||
for _, value := range diagnostics {
|
||||
if strings.Contains(value, "opaque-runtime-key") || strings.Contains(value, "/Users/") || strings.Contains(value, "password=") {
|
||||
t.Fatalf("diagnostics exposed sensitive value: %+v", diagnostics)
|
||||
}
|
||||
}
|
||||
if diagnostics["keyFingerprint"] == "" || diagnostics["secretRef"] != "secret://runtime-keys/server-1/run/current" {
|
||||
t.Fatalf("expected redacted key fingerprint and secret ref, got %+v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPackageConfigRejectsUnsafeOrIncompletePackages(t *testing.T) {
|
||||
valid := PackageConfig{
|
||||
Kind: PackageComponentRun,
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||
KeyGeneration: 1,
|
||||
AuthKey: "opaque-runtime-key",
|
||||
}
|
||||
cases := map[string]func(PackageConfig) PackageConfig{
|
||||
"old zero generation": func(cfg PackageConfig) PackageConfig { cfg.KeyGeneration = 0; return cfg },
|
||||
"raw path": func(cfg PackageConfig) PackageConfig { cfg.ServerInstanceID = "/Users/tasia/server"; return cfg },
|
||||
"socket": func(cfg PackageConfig) PackageConfig { cfg.SecretRef = "unix:///tmp/run.sock"; return cfg },
|
||||
"secret auth": func(cfg PackageConfig) PackageConfig { cfg.AuthKey = "password=raw"; return cfg },
|
||||
"client missing key": func(cfg PackageConfig) PackageConfig { cfg.Kind = PackageComponentClientManager; return cfg },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
if err := ValidatePackageConfig(mutate(valid)); err == nil {
|
||||
t.Fatalf("expected %s package to be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatePackageGenerationRejectsStalePackages(t *testing.T) {
|
||||
pkg := PackageConfig{
|
||||
Kind: PackageComponentRun,
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.minecraft",
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
SecretRef: "secret://runtime-keys/server-1/run/current",
|
||||
KeyGeneration: 1,
|
||||
AuthKey: "opaque-runtime-key",
|
||||
}
|
||||
err := AuthenticatePackageGeneration(pkg, ComponentAuthResult{
|
||||
ServerInstanceID: "server-1",
|
||||
Kind: PackageComponentRun,
|
||||
KeyGeneration: 2,
|
||||
Allowed: true,
|
||||
Reason: "current key accepted",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "generation") {
|
||||
t.Fatalf("expected stale generation rejection, got %v", err)
|
||||
}
|
||||
err = AuthenticatePackageGeneration(pkg, ComponentAuthResult{
|
||||
ServerInstanceID: "server-1",
|
||||
Kind: PackageComponentRun,
|
||||
KeyGeneration: 1,
|
||||
Allowed: true,
|
||||
Reason: "current key accepted",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected current generation to authenticate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPackageConfigFromEnv(t *testing.T) {
|
||||
path := writePackageConfig(t, PackageConfig{
|
||||
Kind: PackageComponentClientManager,
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
ProfileKey: "scum-client-manager",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
SecretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
||||
KeyGeneration: 4,
|
||||
AuthKey: "opaque-client-key",
|
||||
})
|
||||
t.Setenv(PackageConfigEnv, path)
|
||||
|
||||
cfg, ok, err := LoadPackageConfigFromEnv()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expected env package config, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if cfg.Kind != PackageComponentClientManager || cfg.ProfileKey != "scum-client-manager" {
|
||||
t.Fatalf("unexpected package config: %+v", cfg)
|
||||
}
|
||||
|
||||
t.Setenv(PackageConfigEnv, "")
|
||||
_, ok, err = LoadPackageConfigFromEnv()
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected no env package config, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePackageConfig(t *testing.T, cfg PackageConfig) string {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal package config: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "run-package.json")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatalf("write package config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -17,6 +17,11 @@ type RunCapabilityReport struct {
|
||||
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"`
|
||||
|
||||
+23
-7
@@ -3,13 +3,29 @@ package protocol
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RunCapabilityProcessInstall = "process.install"
|
||||
RunCapabilityProcessStart = "process.start"
|
||||
RunCapabilityProcessStop = "process.stop"
|
||||
RunCapabilityLogsRead = "logs.read"
|
||||
RunCapabilityConfigWrite = "config.write"
|
||||
RunCapabilityFilesRead = "files.read"
|
||||
RunCapabilityFilesWrite = "files.write"
|
||||
RunCapabilityProcessInstall = "process.install"
|
||||
RunCapabilityProcessStart = "process.start"
|
||||
RunCapabilityProcessStop = "process.stop"
|
||||
RunCapabilityLogsRead = "logs.read"
|
||||
RunCapabilityConfigWrite = "config.write"
|
||||
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"
|
||||
RunCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
RunCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
RunCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
RunCapabilityRunSelfUpdate = "run.self-update"
|
||||
RunCapabilityDependenciesCheck = "dependencies.check"
|
||||
RunCapabilityDependenciesInstall = "dependencies.install"
|
||||
RunCapabilityLogsBackfill = "logs.backfill"
|
||||
)
|
||||
|
||||
type RunJobProgressReport struct {
|
||||
|
||||
@@ -39,6 +39,23 @@ Platform-dispatched config/file jobs are now represented in the run job payload
|
||||
- `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`: stages an approved run artifact by `artifact://...` ref, verifies checksum/signature metadata, and reports a rollback-safe status ref.
|
||||
- `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/...`; arbitrary shell snippets are rejected by validation.
|
||||
- `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
|
||||
@@ -47,6 +64,8 @@ The executor resolves lifecycle action templates under the scoped server workspa
|
||||
- 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.
|
||||
|
||||
@@ -23,6 +23,49 @@ func ValidateRunJobAssignment(assignment RunJobAssignment) error {
|
||||
return ValidationError("inputRef is not allowed")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
switch assignment.Capability {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -56,3 +99,30 @@ func ValidScopedInputRef(ref string) bool {
|
||||
}
|
||||
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:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,3 +44,81 @@ func TestValidateRunJobAssignmentScopedReadDoesNotRequireInputRef(t *testing.T)
|
||||
t.Fatalf("expected valid file read assignment: %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",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(backfill); err != nil {
|
||||
t.Fatalf("expected valid log backfill assignment: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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