diff --git a/docs/commands.md b/docs/commands.md index 42dd9b0c..7599802c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -135,6 +135,20 @@ writer follows the gateway's current address. The `--output-env` file is a map of the run's current environment, rewritten whole: a key written again replaces its line, so a reader never meets two values for one key. +**What `--output-env` writes, and for which environment.** The file is the +service's process environment as the agent receives it, owner-only (`0600`): +its own configuration and runtime configurations, the workspace groups it is +handed, and — written once at Start, after the dependency barrier — its +dependencies' configurations; secret values are in it, which is why it is +owner-only and never committed. A workspace group that is one YAML or JSON +**structured document** rather than key/value pairs (the composition root's +merged `fixture-composition`, for example) is carried under a key that hashes +the group's origin, its name and the **selected run environment** — the same +key the SDK derives to read it back — so every write names the environment the +run selected (`--env`), the excluded root's file included. A writer handed no +environment refuses by name rather than writing a document no reader could +find. + **The delivery host's federation surface.** In a host environment the native run hands the service `host.delivery` names, when it declares the `federation` workspace configuration, the three keys that turn its delivery surface on, diff --git a/pkg/orchestration/excluded_root_output_env.go b/pkg/orchestration/excluded_root_output_env.go index 175d6b49..d6777ed0 100644 --- a/pkg/orchestration/excluded_root_output_env.go +++ b/pkg/orchestration/excluded_root_output_env.go @@ -69,9 +69,14 @@ func (flow *Flow) exportExcludedOriginEnvironment(ctx context.Context) error { return fmt.Errorf("resolve excluded root dependency endpoints: %w", err) } + environment, err := flow.world.Env.Proto() + if err != nil { + return fmt.Errorf("name the run environment for the excluded root's output environment: %w", err) + } if err := AppendServiceProcessConfigurationsToFile( ctx, flow.outputEnvPath, + environment, runtimeContext, serviceConfiguration, workspaceConfigurations, diff --git a/pkg/orchestration/runner.go b/pkg/orchestration/runner.go index fe9c4be6..bcc78b07 100644 --- a/pkg/orchestration/runner.go +++ b/pkg/orchestration/runner.go @@ -372,9 +372,14 @@ func (runner *Runner) Init(ctx context.Context) (*OutputProperty, error) { // every dependency secret in the owner-only file (and record stale // pre-barrier values). Own, workspace, and this service's own runtime // configurations are final at Init, so they are written now. + environment, envErr := runner.world.Env.Proto() + if envErr != nil { + return nil, w.Wrapf(envErr, "cannot name the run environment for the output environment") + } err = AppendServiceProcessConfigurationsToFile( ctx, runner.outputEnv, + environment, runtimeContext, conf, workspaceConfigurations, @@ -980,9 +985,14 @@ func (runner *Runner) Start(ctx context.Context) (*OutputProperty, error) { if dependencyErr != nil { return nil, w.Wrapf(dependencyErr, "cannot load dependency configurations for output environment") } + environment, envErr := runner.world.Env.Proto() + if envErr != nil { + return nil, w.Wrapf(envErr, "cannot name the run environment for the output environment") + } err = AppendServiceProcessConfigurationsToFile( ctx, runner.outputEnv, + environment, runtimeContext, nil, nil, @@ -1829,11 +1839,12 @@ func (runner *Runner) WithRemote(environment *environments.Environment) { runner.remoteEnvironment = environment } -func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, confs []*basev0.Configuration) error { +func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, environment *basev0.Environment, confs []*basev0.Configuration) error { w := wool.Get(ctx).In("resources.AppendToFile", wool.Field("filePath", filePath)) // filter out for native filtered := resources.FilterConfigurations(confs, resources.NewRuntimeContextNative()) m := resources.NewEnvironmentVariableManager() + m.SetEnvironment(environment) err := m.AddConfigurations(ctx, filtered...) if err != nil { return w.Wrapf(err, "cannot add configurations") @@ -1852,9 +1863,22 @@ func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, conf // runtime configurations are filtered to the selected backend. The output is // owner-only because these inputs intentionally include provider credentials // and dependency connection secrets. +// +// The run's environment is part of what is written, not decoration: a +// structured configuration document (a workspace group that is one YAML or +// JSON document rather than key/value pairs, such as the composition root's +// fixture-composition) is carried under a key that hashes its origin, its name +// AND the environment, which is how the SDK finds it again +// (resources.ConfigurationDocumentKey). The agent's own Init request carries +// the environment and so could always deliver such a document; this writer +// built its manager without one, so the first root-owned document a composition +// handed every service made `--output-env` refuse the whole run at Init with +// core's "configuration document requires origin, name and environment" — the +// shape platform-core's composition gate met on 0.1.219. func AppendServiceProcessConfigurationsToFile( ctx context.Context, filePath string, + environment *basev0.Environment, runtimeContext *basev0.RuntimeContext, serviceConfiguration *basev0.Configuration, workspaceConfigurations []*basev0.Configuration, @@ -1862,7 +1886,11 @@ func AppendServiceProcessConfigurationsToFile( runtimeConfigurations []*basev0.Configuration, ) error { w := wool.Get(ctx).In("resources.AppendServiceProcessConfigurationsToFile", wool.Field("filePath", filePath)) + if environment == nil || environment.GetName() == "" { + return w.NewError("the service process environment names no run environment, so a configuration document could not be carried under the key its reader derives") + } manager := resources.NewEnvironmentVariableManager() + manager.SetEnvironment(environment) if err := manager.AddConfigurations(ctx, serviceConfiguration); err != nil { return w.Wrapf(err, "cannot add service configuration") } diff --git a/pkg/orchestration/runner_output_env_test.go b/pkg/orchestration/runner_output_env_test.go index 1c9cd0c9..01ef85d2 100644 --- a/pkg/orchestration/runner_output_env_test.go +++ b/pkg/orchestration/runner_output_env_test.go @@ -18,7 +18,7 @@ func TestAppendEnvironmentVariablesToFileIsOwnerOnly(t *testing.T) { t.Fatal(err) } - if err := AppendEnvironmentVariablesToFile(context.Background(), path, nil); err != nil { + if err := AppendEnvironmentVariablesToFile(context.Background(), path, localRunEnvironment(), nil); err != nil { t.Fatal(err) } info, err := os.Stat(path) @@ -40,7 +40,7 @@ func TestAppendEnvironmentVariablesToFileRejectsSymlink(t *testing.T) { t.Fatal(err) } - if err := AppendEnvironmentVariablesToFile(context.Background(), link, nil); err == nil { + if err := AppendEnvironmentVariablesToFile(context.Background(), link, localRunEnvironment(), nil); err == nil { t.Fatal("expected symlink rejection") } } @@ -130,9 +130,17 @@ func TestAppendServiceProcessConfigurationsToFileIncludesSecretsAndFiltersDepend ) runtime := configuration("mind/mind", "runtime", "session_id", "session-123", false, native) + // The composition root's own structured document, handed to every service + // of the run as platform-core's merged fixture-composition is: it is + // carried under a key that hashes origin, name AND environment, so a + // writer that names no environment cannot write it at all. + document := &basev0.Configuration{Origin: resources.ConfigurationWorkspace, RuntimeContext: native, Infos: []*basev0.ConfigurationInformation{{ + Name: "fixture-composition", + Data: &basev0.ConfigurationData{Kind: "yaml", Content: []byte("schema: codefly.dev/authority/fixture-composition/v1\nroles: []\n")}, + }}} if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, service, - []*basev0.Configuration{workspace}, + context.Background(), path, localRunEnvironment(), native, service, + []*basev0.Configuration{workspace, document}, []*basev0.Configuration{postgres, containerOnly}, []*basev0.Configuration{runtime}, ); err != nil { @@ -148,11 +156,21 @@ func TestAppendServiceProcessConfigurationsToFileIncludesSecretsAndFiltersDepend "CODEFLY__WORKSPACE_CONFIGURATION__DOGFOOD__CAMPAIGN=headless\n", "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__READ_WRITE_CONNECTION=postgres://runtime\n", "CODEFLY__SERVICE_CONFIGURATION__MIND__MIND__RUNTIME__SESSION_ID=session-123\n", + // The document, under the key its reader derives for this environment. + resources.ConfigurationDocumentKey(resources.ConfigurationWorkspace, "fixture-composition", "local", false) + "=", } { if !strings.Contains(text, expected) { t.Fatalf("service process environment is missing %q; keys=%v", expected, environmentKeys(text)) } } + carrier := carrierValue(t, text, resources.ConfigurationDocumentKey(resources.ConfigurationWorkspace, "fixture-composition", "local", false)) + content, err := resources.DecodeConfigurationDocument(carrier, resources.ConfigurationWorkspace, "fixture-composition", "local", false) + if err != nil { + t.Fatalf("the written document does not read back for environment local: %v", err) + } + if !strings.Contains(string(content), "codefly.dev/authority/fixture-composition/v1") { + t.Fatalf("the document lost its content: %s", content) + } if strings.Contains(text, "REDIS") || strings.Contains(text, "redis://container") { t.Fatalf("container-only dependency leaked into native environment; keys=%v", environmentKeys(text)) } @@ -192,13 +210,13 @@ func TestServiceProcessOutputEnvWritesDependencyConfigsOncePerRun(t *testing.T) // Init phase: own + runtime only, NO dependency configurations. if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, own, nil, nil, []*basev0.Configuration{runtime}, + context.Background(), path, localRunEnvironment(), native, own, nil, nil, []*basev0.Configuration{runtime}, ); err != nil { t.Fatal(err) } // Start phase: refreshed dependency configurations only. if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, nil, nil, []*basev0.Configuration{dependency}, nil, + context.Background(), path, localRunEnvironment(), native, nil, nil, []*basev0.Configuration{dependency}, nil, ); err != nil { t.Fatal(err) } @@ -400,3 +418,35 @@ func TestAppendRuntimeEnvironmentToFileOmitsForbiddenDependencyEndpoint(t *testi t.Fatalf("private endpoint reached the output environment:\n%s", text) } } + +// localRunEnvironment is the run environment the writers name their carriers +// for, as the runner reads it off the world. +func localRunEnvironment() *basev0.Environment { return &basev0.Environment{Name: "local"} } + +// carrierValue is the value written for key in a KEY=value file. +func carrierValue(t *testing.T, text, key string) string { + t.Helper() + for _, line := range strings.Split(text, "\n") { + if value, ok := strings.CutPrefix(line, key+"="); ok { + return value + } + } + t.Fatalf("%s is not in the file; keys=%v", key, environmentKeys(text)) + return "" +} + +// A writer that names no run environment refuses before it writes anything, +// by name, rather than failing on the first document with core's contract +// error — which is what platform-core's composition gate saw on 0.1.219: the +// composition root's merged fixture-composition reached every service's +// `--output-env` and the whole run refused at the first Init. +func TestServiceProcessOutputEnvRequiresTheRunEnvironment(t *testing.T) { + path := filepath.Join(t.TempDir(), "runtime.env") + err := AppendServiceProcessConfigurationsToFile(context.Background(), path, nil, resources.NewRuntimeContextNative(), nil, nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "names no run environment") { + t.Fatalf("got %v, want the writer to refuse without an environment", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("a refused write left a file: %v", statErr) + } +}