diff --git a/config/config.go b/config/config.go index 7eecf09..ddf2bf6 100644 --- a/config/config.go +++ b/config/config.go @@ -69,18 +69,19 @@ func Load() Config { } workspaceRoot := envOrDefault("RUN_WORKSPACE_ROOT", filepath.Join(".", ".run-workspace")) + identity := loadPackagedIdentity() return Config{ Mode: mode, PlatformURL: platformURL, - RunEndpointID: envOrDefault("RUN_ENDPOINT_ID", stringOrDefault(BuildRunEndpointID, DefaultEndpointID)), + RunEndpointID: identity.runEndpointID, DisplayName: envOrDefault("RUN_DISPLAY_NAME", stringOrDefault(BuildDisplayName, DefaultDisplayName)), - Version: envOrDefault("RUN_VERSION", BuildVersion), - RegistrationToken: envOrDefault("RUN_REGISTRATION_TOKEN", stringOrDefault(BuildRegistrationToken, "local-registration")), - ServerInstanceID: envOrDefault("RUN_SERVER_INSTANCE_ID", BuildServerInstanceID), - PluginID: envOrDefault("RUN_PLUGIN_ID", BuildPluginID), - ComponentKind: envOrDefault("RUN_COMPONENT_KIND", BuildComponentKind), - ComponentKey: envOrDefault("RUN_COMPONENT_KEY", BuildComponentKey), - KeyGeneration: intEnvOrDefault("RUN_KEY_GENERATION", intStringOrDefault(BuildKeyGeneration, 0)), + Version: identity.version, + RegistrationToken: identity.registrationToken, + ServerInstanceID: identity.serverInstanceID, + PluginID: identity.pluginID, + ComponentKind: identity.componentKind, + ComponentKey: identity.componentKey, + KeyGeneration: identity.keyGeneration, WorkspaceSeed: envOrDefault("RUN_WORKSPACE_SEED", BuildWorkspaceSeed), WorkspaceRoot: workspaceRoot, BuildSourceRoot: envOrDefault("RUN_BUILD_SOURCE_ROOT", "."), @@ -98,6 +99,42 @@ func Load() Config { } } +type packagedIdentity struct { + runEndpointID string + version string + registrationToken string + serverInstanceID string + pluginID string + componentKind string + componentKey string + keyGeneration int +} + +func loadPackagedIdentity() packagedIdentity { + if BuildRegistrationToken != "" { + return packagedIdentity{ + runEndpointID: stringOrDefault(BuildRunEndpointID, DefaultEndpointID), + version: stringOrDefault(BuildVersion, DefaultVersion), + registrationToken: BuildRegistrationToken, + serverInstanceID: BuildServerInstanceID, + pluginID: BuildPluginID, + componentKind: BuildComponentKind, + componentKey: BuildComponentKey, + keyGeneration: intStringOrDefault(BuildKeyGeneration, 0), + } + } + return packagedIdentity{ + runEndpointID: envOrDefault("RUN_ENDPOINT_ID", stringOrDefault(BuildRunEndpointID, DefaultEndpointID)), + version: envOrDefault("RUN_VERSION", BuildVersion), + registrationToken: envOrDefault("RUN_REGISTRATION_TOKEN", "local-registration"), + serverInstanceID: envOrDefault("RUN_SERVER_INSTANCE_ID", BuildServerInstanceID), + pluginID: envOrDefault("RUN_PLUGIN_ID", BuildPluginID), + componentKind: envOrDefault("RUN_COMPONENT_KIND", BuildComponentKind), + componentKey: envOrDefault("RUN_COMPONENT_KEY", BuildComponentKey), + keyGeneration: intEnvOrDefault("RUN_KEY_GENERATION", intStringOrDefault(BuildKeyGeneration, 0)), + } +} + func stringOrDefault(value string, fallback string) string { if value == "" { return fallback diff --git a/config/config_test.go b/config/config_test.go index 0c244e5..75b85cd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -53,7 +53,7 @@ func TestLoadUsesEnvironment(t *testing.T) { } } -func TestLoadUsesBuildDefaultsWithEnvironmentOverride(t *testing.T) { +func TestLoadUsesPackagedIdentityOverEnvironment(t *testing.T) { oldMode, oldPlatformURL, oldRunEndpointID, oldDisplayName := BuildMode, BuildPlatformURL, BuildRunEndpointID, BuildDisplayName oldRegistrationToken, oldServerInstanceID, oldPluginID := BuildRegistrationToken, BuildServerInstanceID, BuildPluginID oldComponentKind, oldComponentKey, oldKeyGeneration, oldVersion, oldWorkspaceSeed := BuildComponentKind, BuildComponentKey, BuildKeyGeneration, BuildVersion, BuildWorkspaceSeed @@ -85,9 +85,16 @@ func TestLoadUsesBuildDefaultsWithEnvironmentOverride(t *testing.T) { } t.Setenv("RUN_PLATFORM_URL", "http://127.0.0.1:18080") + t.Setenv("RUN_ENDPOINT_ID", "stale-run") + t.Setenv("RUN_REGISTRATION_TOKEN", "stale-token") + t.Setenv("RUN_SERVER_INSTANCE_ID", "stale-server") + t.Setenv("RUN_PLUGIN_ID", "stale-plugin") + t.Setenv("RUN_COMPONENT_KIND", "client-manager") + t.Setenv("RUN_COMPONENT_KEY", "stale-profile") t.Setenv("RUN_KEY_GENERATION", "7") + t.Setenv("RUN_VERSION", "stale-version") cfg = Load() - if cfg.PlatformURL != "http://127.0.0.1:18080" || cfg.KeyGeneration != 7 { - t.Fatalf("expected environment override, got %+v", cfg) + if cfg.PlatformURL != "http://127.0.0.1:18080" || cfg.RunEndpointID != "run-server-1" || cfg.RegistrationToken != "compiled-run-key" || cfg.ServerInstanceID != "server-1" || cfg.PluginID != "game.scum" || cfg.ComponentKind != "run" || cfg.ComponentKey != "" || cfg.KeyGeneration != 5 || cfg.Version != "run-dist-1" { + t.Fatalf("expected packaged identity with platform URL override, got %+v", cfg) } } diff --git a/config/package_config.go b/config/package_config.go index e7549cd..3a897c8 100644 --- a/config/package_config.go +++ b/config/package_config.go @@ -138,7 +138,7 @@ func ApplyPackageConfig(base Config, pkg PackageConfig) Config { base.ServerInstanceID = pkg.ServerInstanceID base.PluginID = pkg.PluginID base.ComponentKind = pkg.Kind - base.ComponentKey = pkg.ProfileKey + base.ComponentKey = packageComponentKey(pkg) base.KeyGeneration = pkg.KeyGeneration base.SecretRef = pkg.SecretRef if pkg.RunEndpointID != "" { @@ -184,7 +184,7 @@ func AuthenticatePackageGeneration(pkg PackageConfig, auth ComponentAuthResult) if err := ValidatePackageConfig(pkg); err != nil { return err } - if auth.ServerInstanceID != pkg.ServerInstanceID || auth.Kind != pkg.Kind || auth.ProfileKey != pkg.ProfileKey { + if auth.ServerInstanceID != pkg.ServerInstanceID || auth.Kind != pkg.Kind || auth.ProfileKey != packageComponentKey(pkg) { return fmt.Errorf("component authentication scope does not match package") } if !auth.Allowed { @@ -196,6 +196,13 @@ func AuthenticatePackageGeneration(pkg PackageConfig, auth ComponentAuthResult) return nil } +func packageComponentKey(pkg PackageConfig) string { + if pkg.Kind == PackageComponentRun { + return "" + } + return strings.TrimSpace(pkg.ProfileKey) +} + func fingerprint(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:])[:12] diff --git a/config/package_config_test.go b/config/package_config_test.go index 813fb32..8e1edab 100644 --- a/config/package_config_test.go +++ b/config/package_config_test.go @@ -14,6 +14,7 @@ func TestLoadPackageConfigAppliesServerScopedIdentity(t *testing.T) { ServerInstanceID: "server-1", PluginID: "game.minecraft", RunEndpointID: "run-server-1", + ProfileKey: "run-local", TargetOS: "linux", TargetArch: "amd64", SecretRef: "secret://runtime-keys/server-1/run/current", @@ -26,7 +27,7 @@ func TestLoadPackageConfigAppliesServerScopedIdentity(t *testing.T) { 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 { + if cfg.RegistrationToken != "opaque-runtime-key" || cfg.RunEndpointID != "run-server-1" || cfg.ServerInstanceID != "server-1" || cfg.ComponentKey != "" || cfg.KeyGeneration != 3 { t.Fatalf("expected package identity to be applied, got %+v", cfg) } diagnostics := pkg.RedactedDiagnostics() @@ -70,6 +71,7 @@ func TestAuthenticatePackageGenerationRejectsStalePackages(t *testing.T) { Kind: PackageComponentRun, ServerInstanceID: "server-1", PluginID: "game.minecraft", + ProfileKey: "run-local", TargetOS: "linux", TargetArch: "amd64", SecretRef: "secret://runtime-keys/server-1/run/current", diff --git a/runtime/autonomous_lifecycle_test.go b/runtime/autonomous_lifecycle_test.go index 61e9d6e..b6c0783 100644 --- a/runtime/autonomous_lifecycle_test.go +++ b/runtime/autonomous_lifecycle_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "encoding/base64" "encoding/json" "errors" "os" @@ -14,6 +15,54 @@ import ( "browser.local/run/protocol" ) +func TestMaterializeWorkspaceSeedUsesLifecycleProfileWhenRunComponentKeyIsEmpty(t *testing.T) { + cfg := workerTestConfig(t) + cfg.ServerInstanceID = "server-worker" + cfg.PluginID = "game.scum" + cfg.ComponentKind = "run" + cfg.ComponentKey = "" + plan := protocol.RunAutonomousLifecyclePlan{ + SchemaVersion: "1", + ServerInstanceID: cfg.ServerInstanceID, + PluginID: "game.scum", + PluginVersion: "1.0.0", + RunEndpointID: cfg.RunEndpointID, + ProfileKey: "run-local", + TargetOS: runtime.GOOS, + TargetArch: runtime.GOARCH, + TargetRelease: "run-dist-test", + Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"}, + } + planPayload, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal plan: %v", err) + } + seedPayload, err := json.Marshal([]workspaceSeedFile{ + {Path: "actions/start.json", Content: `{"version":1,"action":"start","mode":"supervised","executableKey":"bin/game-server"}`, Mode: 0o600}, + {Path: "bin/game-server", Content: "plugin-owned executable", Mode: 0o700}, + {Path: autonomousLifecyclePlanKey, Content: string(planPayload), Mode: 0o600}, + }) + if err != nil { + t.Fatalf("marshal seed: %v", err) + } + cfg.WorkspaceSeed = base64.StdEncoding.EncodeToString(seedPayload) + + if err := MaterializeWorkspaceSeed(cfg); err != nil { + t.Fatalf("materialize workspace seed: %v", err) + } + profileScope, err := NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, "run-local") + if err != nil { + t.Fatalf("resolve profile scope: %v", err) + } + if _, err := os.Stat(filepath.Join(profileScope, autonomousLifecyclePlanKey)); err != nil { + t.Fatalf("expected lifecycle plan under profile scope: %v", err) + } + loaded, scope, ok, err := LoadAutonomousLifecyclePlan(cfg) + if err != nil || !ok || loaded.ProfileKey != "run-local" || scope != profileScope { + t.Fatalf("expected lifecycle plan to load from profile scope, ok=%t scope=%q plan=%+v err=%v", ok, scope, loaded, err) + } +} + func TestWorkerRunsAutonomousBootstrapFromSeededPlan(t *testing.T) { client := newFakeWorkerClient() cfg := workerTestConfig(t) diff --git a/runtime/distribution_build.go b/runtime/distribution_build.go index 3b17678..aecd039 100644 --- a/runtime/distribution_build.go +++ b/runtime/distribution_build.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "browser.local/run/config" "browser.local/run/protocol" ) @@ -330,7 +331,7 @@ func buildRunLDFlags(input protocol.DistributionBuildInputResponse, platformURL "BuildServerInstanceID": input.ServerInstanceID, "BuildPluginID": input.PluginID, "BuildComponentKind": input.ComponentKind, - "BuildComponentKey": input.ProfileKey, + "BuildComponentKey": runBuildComponentKey(input), "BuildKeyGeneration": fmt.Sprint(input.KeyGeneration), "BuildVersion": input.TargetRelease, } @@ -341,6 +342,13 @@ func buildRunLDFlags(input protocol.DistributionBuildInputResponse, platformURL return strings.Join(flags, " ") } +func runBuildComponentKey(input protocol.DistributionBuildInputResponse) string { + if input.ComponentKind == config.PackageComponentRun { + return "" + } + return strings.TrimSpace(input.ProfileKey) +} + func (worker *Worker) uploadDistributionArtifact(ctx context.Context, assignment protocol.RunJobAssignment, artifactID string, payload []byte) error { checksum := bytesChecksum(payload) state, err := worker.registeredState() diff --git a/runtime/distribution_build_test.go b/runtime/distribution_build_test.go index 4a94768..fe6bf87 100644 --- a/runtime/distribution_build_test.go +++ b/runtime/distribution_build_test.go @@ -136,6 +136,7 @@ func TestDistributionBuildIsolationUsesPluginJobWorkspaceAndDistinctPackageState ServerInstanceID: firstAssignment.ServerInstanceID, PluginID: "game.scum", RunEndpointID: firstAssignment.RunEndpointID, + ProfileKey: "run-local", TargetOS: "linux", TargetArch: "amd64", PlatformURL: "https://scum.npc0.com", @@ -166,6 +167,9 @@ func TestDistributionBuildIsolationUsesPluginJobWorkspaceAndDistinctPackageState if !strings.Contains(firstFlags, "BuildServerInstanceID=scum-alpha") || !strings.Contains(firstFlags, "BuildRegistrationToken=alpha-component-key") { t.Fatalf("expected first build flags to carry first server identity, got %q", firstFlags) } + if strings.Contains(firstFlags, "BuildComponentKey=run-local") || !strings.Contains(firstFlags, "BuildComponentKey=") { + t.Fatalf("expected run component identity to stay separate from lifecycle profile, got %q", firstFlags) + } if !strings.Contains(secondFlags, "BuildServerInstanceID=scum-beta") || !strings.Contains(secondFlags, "BuildRegistrationToken=beta-component-key") { t.Fatalf("expected second build flags to carry second server identity, got %q", secondFlags) } diff --git a/runtime/workspace_seed.go b/runtime/workspace_seed.go index b946663..4133446 100644 --- a/runtime/workspace_seed.go +++ b/runtime/workspace_seed.go @@ -51,7 +51,7 @@ func MaterializeWorkspaceSeed(cfg config.Config) error { log.Printf("RUN phase=workspace_seed status=failed reason=missing_server workspace=%s", safeOptional(cfg.WorkspaceRoot)) return fmt.Errorf("workspace seed requires a server instance id") } - scope, err := seededWorkspaceScope(cfg) + scope, err := seededWorkspaceScopeForFiles(cfg, files) if err != nil { log.Printf("RUN phase=workspace_seed status=scope_failed workspace=%s server=%s componentKey=%s error=%s", safeOptional(cfg.WorkspaceRoot), safeOptional(cfg.ServerInstanceID), safeOptional(cfg.ComponentKey), RedactText(err.Error())) return err @@ -71,12 +71,69 @@ func MaterializeWorkspaceSeed(cfg config.Config) error { } func seededWorkspaceScope(cfg config.Config) (string, error) { + return seededWorkspaceScopeForFiles(cfg, nil) +} + +func seededWorkspaceScopeForFiles(cfg config.Config, files []workspaceSeedFile) (string, error) { if strings.TrimSpace(cfg.ComponentKey) != "" { return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, cfg.ComponentKey) } + profileKey, err := workspaceSeedProfileKey(cfg, files) + if err != nil { + return "", err + } + if profileKey != "" { + return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, profileKey) + } return scopedServerWorkspace(cfg.WorkspaceRoot, cfg.ServerInstanceID) } +func workspaceSeedProfileKey(cfg config.Config, files []workspaceSeedFile) (string, error) { + var err error + if len(files) == 0 && strings.TrimSpace(cfg.WorkspaceSeed) != "" { + files, err = decodeWorkspaceSeedFiles(cfg.WorkspaceSeed) + if err != nil { + return "", err + } + } + for _, file := range files { + if filepath.ToSlash(strings.TrimSpace(file.Path)) != autonomousLifecyclePlanKey { + continue + } + body, err := workspaceSeedFileContent(file) + if err != nil { + return "", err + } + var plan struct { + ProfileKey string `json:"profileKey,omitempty"` + } + if err := json.Unmarshal(body, &plan); err != nil { + return "", fmt.Errorf("decode workspace seed lifecycle profile: %w", err) + } + profileKey := strings.TrimSpace(plan.ProfileKey) + if profileKey == "" { + return "", nil + } + if !protocol.ValidLogicalFileKey(profileKey) { + return "", fmt.Errorf("workspace seed lifecycle profile is unsafe") + } + return profileKey, nil + } + return "", nil +} + +func decodeWorkspaceSeedFiles(encoded string) ([]workspaceSeedFile, error) { + payload, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("decode workspace seed: %w", err) + } + var files []workspaceSeedFile + if err := json.Unmarshal(payload, &files); err != nil { + return nil, fmt.Errorf("decode workspace seed manifest: %w", err) + } + return files, nil +} + func writeWorkspaceSeedFile(scope string, file workspaceSeedFile, index int, total int) (int, error) { target, err := workspaceSeedTarget(scope, file.Path) if err != nil {